feat(grid): load large-value previews by viewport
This commit is contained in:
6 files changed
+721
-16
No files matched your search
@@ -181,7 +181,20 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowSho
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||
import { createResultScopedPendingRequests, largeValueCellKey, largeValueCellMap, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import {
|
||||
createResultScopedPendingRequests,
|
||||
createResultScopedRowCache,
|
||||
isTableDataVisiblePreviewColumn,
|
||||
largeValueCellKey,
|
||||
largeValueCellMap,
|
||||
TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES,
|
||||
TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS,
|
||||
TABLE_DATA_VISIBLE_PREVIEW_SIZE,
|
||||
tableDataLargeValuePreviewOptions,
|
||||
tableDataVisiblePreviewContentBytes,
|
||||
tableDataVisiblePreviewRowRange,
|
||||
type ResultScopedRowCache,
|
||||
} from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
@@ -2899,6 +2912,7 @@ function refreshGridScrollerMetrics() {
|
||||
headerRef.value.scrollLeft = scrollerEl.scrollLeft;
|
||||
}
|
||||
observeGridHorizontalScrollbarScroller();
|
||||
scheduleVisibleLargeValuePreviewHydration();
|
||||
}
|
||||
|
||||
let columnLayoutRefreshFrame = 0;
|
||||
@@ -2943,6 +2957,7 @@ function markGridScrolling() {
|
||||
const scroller = gridScrollerElement();
|
||||
if (scroller) updateDomGridVisibleItemsDuringScroll(scroller);
|
||||
isScrolling.value = false;
|
||||
scheduleVisibleLargeValuePreviewHydration();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
@@ -3582,6 +3597,7 @@ const editor = useDataGridEditor({
|
||||
currentPage,
|
||||
cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey),
|
||||
onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result),
|
||||
onCellValueChanged: invalidateVisibleLargeValuePreviewCell,
|
||||
prepareFullReload,
|
||||
emit,
|
||||
});
|
||||
@@ -4330,7 +4346,7 @@ const displayRowIndexByIdLookup = computed(() => {
|
||||
});
|
||||
|
||||
function rowItemFromDisplayRef(ref: DisplayRowRef): RowItem {
|
||||
largeValueResolutionVersion.value;
|
||||
void largeValueResolutionVersion.value;
|
||||
if (ref.isNew) {
|
||||
return {
|
||||
...ref,
|
||||
@@ -4523,6 +4539,19 @@ const LARGE_VALUE_FETCH_MAX_ROWS = 200;
|
||||
const LARGE_VALUE_FETCH_TARGET_BYTES = 64 * 1024 * 1024;
|
||||
const pendingLargeValueHydrations = createResultScopedPendingRequests<boolean>();
|
||||
const largeValueCellsByKey = computed(() => largeValueCellMap(props.result));
|
||||
type VisibleLargeValuePreviewRequest = {
|
||||
item: RowItem;
|
||||
sourceIndex: number;
|
||||
initialValues: Map<number, { value: CellValue; revision: number }>;
|
||||
};
|
||||
const visibleLargeValuePreviewCaches = new WeakMap<QueryResult, ResultScopedRowCache<CellValue>>();
|
||||
const visibleLargeValuePreviewCellRevisions = new WeakMap<QueryResult, Map<string, number>>();
|
||||
const failedVisibleLargeValuePreviewResults = new WeakSet<QueryResult>();
|
||||
const visibleLargeValuePreviewVersion = ref(0);
|
||||
let visibleLargeValuePreviewTimer = 0;
|
||||
let visibleLargeValuePreviewRequestedGeneration = 0;
|
||||
let visibleLargeValuePreviewActive = true;
|
||||
const visibleLargeValuePreviewExecutionIds = new Map<number, string>();
|
||||
|
||||
function isLargeValuePreview(item: RowItem | undefined, columnIndex: number): boolean {
|
||||
if (!item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return false;
|
||||
@@ -4535,7 +4564,8 @@ function largeValueOriginalBytes(item: Pick<RowItem, "sourceIndex" | "isNew" | "
|
||||
}
|
||||
|
||||
function formatGridItemCell(item: RowItem, columnIndex: number): string {
|
||||
return formatCellCached(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex));
|
||||
void visibleLargeValuePreviewVersion.value;
|
||||
return formatCellCached(visibleLargeValuePreviewValue(item, columnIndex, item.data[columnIndex] ?? null), columnIndex, largeValueOriginalBytes(item, columnIndex));
|
||||
}
|
||||
|
||||
function formatGridItemCellForConfirmation(item: RowItem, columnIndex: number): string {
|
||||
@@ -4556,6 +4586,248 @@ function largeValueSourceColumnIndex(columnName: string): number {
|
||||
return resultSourceColumns.value.findIndex((column) => column.toLocaleLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewCache(result: QueryResult): ResultScopedRowCache<CellValue> {
|
||||
let cache = visibleLargeValuePreviewCaches.get(result);
|
||||
if (!cache) {
|
||||
cache = createResultScopedRowCache(TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS, {
|
||||
maxBytes: TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES,
|
||||
sizeOf: tableDataVisiblePreviewContentBytes,
|
||||
});
|
||||
visibleLargeValuePreviewCaches.set(result, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewCellRevisionKey(sourceIndex: number, columnIndex: number): string {
|
||||
return `${sourceIndex}:${columnIndex}`;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number): number {
|
||||
return visibleLargeValuePreviewCellRevisions.get(result)?.get(visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex)) ?? 0;
|
||||
}
|
||||
|
||||
function bumpVisibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number) {
|
||||
let revisions = visibleLargeValuePreviewCellRevisions.get(result);
|
||||
if (!revisions) {
|
||||
revisions = new Map<string, number>();
|
||||
visibleLargeValuePreviewCellRevisions.set(result, revisions);
|
||||
}
|
||||
const key = visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex);
|
||||
revisions.set(key, (revisions.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function invalidateVisibleLargeValuePreviewCell(rowId: number, columnIndex: number) {
|
||||
const item = getRowItem(rowId);
|
||||
const sourceIndex = item?.sourceIndex ?? (rowId >= 0 ? rowId : undefined);
|
||||
if (sourceIndex === undefined) return;
|
||||
bumpVisibleLargeValuePreviewCellRevision(props.result, sourceIndex, columnIndex);
|
||||
const cache = visibleLargeValuePreviewCaches.get(props.result);
|
||||
if (!cache?.has(sourceIndex, columnIndex)) return;
|
||||
cache.forget(sourceIndex, columnIndex);
|
||||
visibleLargeValuePreviewVersion.value += 1;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewValue(item: Pick<RowItem, "sourceIndex"> | undefined, columnIndex: number, fallback: CellValue): CellValue {
|
||||
if (item?.sourceIndex === undefined) return fallback;
|
||||
const preview = visibleLargeValuePreviewCaches.get(props.result)?.get(item.sourceIndex, columnIndex);
|
||||
return preview === undefined ? fallback : preview;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewColumnIndexes(): number[] {
|
||||
const rendered = renderedGridColumns.value.map((column) => column.actualColIdx);
|
||||
return [...new Set(rendered)].filter((columnIndex) => {
|
||||
const dataType = tableColumnForGridColumn(columnIndex)?.data_type ?? allColumnTypes.value[columnIndex] ?? "";
|
||||
return isTableDataVisiblePreviewColumn(resolvedDatabaseType.value, dataType);
|
||||
});
|
||||
}
|
||||
|
||||
function evictVisibleLargeValuePreviews(cache: ResultScopedRowCache<CellValue>, protectedRows: ReadonlySet<number>): boolean {
|
||||
return cache.evict(protectedRows).length > 0;
|
||||
}
|
||||
|
||||
async function hydrateVisibleLargeValuePreviews(generation: number) {
|
||||
const sourceResult = props.result;
|
||||
const tableMeta = props.tableMeta;
|
||||
const scroller = gridScrollerElement();
|
||||
if (
|
||||
!visibleLargeValuePreviewActive ||
|
||||
generation !== visibleLargeValuePreviewRequestedGeneration ||
|
||||
failedVisibleLargeValuePreviewResults.has(sourceResult) ||
|
||||
showTranspose.value ||
|
||||
(resolvedDatabaseType.value !== "mysql" && resolvedDatabaseType.value !== "postgres") ||
|
||||
!props.connectionId ||
|
||||
!tableMeta?.tableName ||
|
||||
tableMeta.primaryKeys.length === 0 ||
|
||||
!scroller
|
||||
)
|
||||
return;
|
||||
|
||||
const range = tableDataVisiblePreviewRowRange(scroller.scrollTop, scroller.clientHeight, CANVAS_DATA_GRID_ROW_HEIGHT, displayRowCount.value);
|
||||
if (!range) return;
|
||||
const cache = visibleLargeValuePreviewCache(sourceResult);
|
||||
const activeSourceRows = new Set<number>();
|
||||
const requests = new Map<number, VisibleLargeValuePreviewRequest>();
|
||||
const targetColumnIndexes = visibleLargeValuePreviewColumnIndexes();
|
||||
for (let displayIndex = range.start; displayIndex < range.end; displayIndex++) {
|
||||
const item = displayItemAt(displayIndex);
|
||||
if (!item || item.sourceIndex === undefined || item.isNew || item.isDraft) continue;
|
||||
activeSourceRows.add(item.sourceIndex);
|
||||
cache.touch(item.sourceIndex);
|
||||
const initialValues = new Map<number, { value: CellValue; revision: number }>();
|
||||
for (const columnIndex of targetColumnIndexes) {
|
||||
if (!isLargeValuePreview(item, columnIndex) || cache.has(item.sourceIndex, columnIndex)) continue;
|
||||
initialValues.set(columnIndex, {
|
||||
value: item.data[columnIndex] ?? null,
|
||||
revision: visibleLargeValuePreviewCellRevision(sourceResult, item.sourceIndex, columnIndex),
|
||||
});
|
||||
}
|
||||
if (initialValues.size > 0) requests.set(item.sourceIndex, { item, sourceIndex: item.sourceIndex, initialValues });
|
||||
}
|
||||
|
||||
if (requests.size === 0) {
|
||||
if (evictVisibleLargeValuePreviews(cache, activeSourceRows)) {
|
||||
visibleLargeValuePreviewVersion.value += 1;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryKeyIndexes = tableMeta.primaryKeys.map(largeValueSourceColumnIndex);
|
||||
if (primaryKeyIndexes.some((index) => index < 0)) return;
|
||||
const requestedColumnIndexes = [...new Set([...requests.values()].flatMap((request) => [...request.initialValues.keys()]))];
|
||||
const selectedColumns = [...tableMeta.primaryKeys];
|
||||
for (const columnIndex of requestedColumnIndexes) {
|
||||
const sourceColumn = resultSourceColumns.value[columnIndex];
|
||||
if (sourceColumn && !selectedColumns.some((column) => column.toLocaleLowerCase() === sourceColumn.toLocaleLowerCase())) selectedColumns.push(sourceColumn);
|
||||
}
|
||||
const selectedColumnTypes = selectedColumns.map((column) => tableMeta.columns.find((candidate) => candidate.name.toLocaleLowerCase() === column.toLocaleLowerCase())?.data_type ?? "");
|
||||
const predicates = await Promise.all([...requests.values()].map((request) => largeValueRowPredicate(request.item, primaryKeyIndexes)));
|
||||
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
database: tableMeta.database,
|
||||
schema: tableMeta.schema,
|
||||
tableName: tableMeta.tableName,
|
||||
tableType: tableMeta.tableType,
|
||||
catalog: tableMeta.catalog,
|
||||
columns: selectedColumns,
|
||||
columnTypes: selectedColumnTypes,
|
||||
largeValuePreviewSize: TABLE_DATA_VISIBLE_PREVIEW_SIZE,
|
||||
primaryKeys: tableMeta.primaryKeys,
|
||||
whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "),
|
||||
limit: requests.size,
|
||||
offset: 0,
|
||||
});
|
||||
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
|
||||
const connection = connectionStore.getConfig(props.connectionId);
|
||||
const executionId = uuid();
|
||||
visibleLargeValuePreviewExecutionIds.set(generation, executionId);
|
||||
let results: QueryResult[];
|
||||
try {
|
||||
results = await api.executeMulti(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, undefined, executionId, {
|
||||
maxRows: requests.size,
|
||||
fetchSize: requests.size,
|
||||
resultKeyColumns: tableMeta.primaryKeys,
|
||||
tableDataPreview: true,
|
||||
timeoutSecs: queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs),
|
||||
});
|
||||
} finally {
|
||||
if (visibleLargeValuePreviewExecutionIds.get(generation) === executionId) visibleLargeValuePreviewExecutionIds.delete(generation);
|
||||
}
|
||||
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
|
||||
const result = results[0];
|
||||
if (!result || result.execution_error) {
|
||||
throw new Error(result?.error ? translateBackendError(t, result.error) : String(result?.rows?.[0]?.[0] ?? t("grid.largeValueLoadFailed")));
|
||||
}
|
||||
const resultColumnIndexes = selectedColumns.map((column) => {
|
||||
const normalized = column.toLocaleLowerCase();
|
||||
return result.columns.findIndex((resultColumn) => resultColumn.toLocaleLowerCase() === normalized);
|
||||
});
|
||||
if (resultColumnIndexes.some((index) => index < 0)) throw new Error(t("grid.largeValueColumnUnavailable"));
|
||||
const resultPrimaryKeyIndexes = resultColumnIndexes.slice(0, tableMeta.primaryKeys.length);
|
||||
const valuesByIdentity = new Map(result.rows.map((row) => [largeValueIdentityKey(row, resultPrimaryKeyIndexes), row]));
|
||||
const currentLargeValueCells = largeValueCellMap(sourceResult);
|
||||
let changed = false;
|
||||
for (const request of requests.values()) {
|
||||
const currentRow = sourceResult.rows[request.sourceIndex];
|
||||
if (!currentRow) continue;
|
||||
const identity = largeValueIdentityKey(currentRow, primaryKeyIndexes);
|
||||
const resolvedRow = valuesByIdentity.get(identity);
|
||||
if (!resolvedRow) continue;
|
||||
for (const [columnIndex, initialValue] of request.initialValues) {
|
||||
if (!currentLargeValueCells.has(largeValueCellKey(request.sourceIndex, columnIndex)) || currentRow[columnIndex] !== initialValue.value || visibleLargeValuePreviewCellRevision(sourceResult, request.sourceIndex, columnIndex) !== initialValue.revision) continue;
|
||||
const sourceColumn = resultSourceColumns.value[columnIndex];
|
||||
const selectedIndex = selectedColumns.findIndex((column) => column.toLocaleLowerCase() === sourceColumn?.toLocaleLowerCase());
|
||||
const resultIndex = resultColumnIndexes[selectedIndex];
|
||||
if (resultIndex === undefined || resultIndex < 0) continue;
|
||||
const previewValue = resolvedRow[resultIndex] ?? null;
|
||||
cache.remember(request.sourceIndex, columnIndex, previewValue);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (evictVisibleLargeValuePreviews(cache, activeSourceRows)) changed = true;
|
||||
if (!changed) return;
|
||||
visibleLargeValuePreviewVersion.value += 1;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
async function runVisibleLargeValuePreviewHydration(generation: number) {
|
||||
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration) return;
|
||||
const sourceResult = props.result;
|
||||
try {
|
||||
await hydrateVisibleLargeValuePreviews(generation);
|
||||
} catch (error) {
|
||||
if (props.result === sourceResult && generation === visibleLargeValuePreviewRequestedGeneration && !failedVisibleLargeValuePreviewResults.has(sourceResult)) {
|
||||
failedVisibleLargeValuePreviewResults.add(sourceResult);
|
||||
appendDebugLog("warn", "[DBX][DataGrid:visible-large-value-preview] disabled for result", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelVisibleLargeValuePreviewHydrations() {
|
||||
for (const [generation, executionId] of visibleLargeValuePreviewExecutionIds) {
|
||||
visibleLargeValuePreviewExecutionIds.delete(generation);
|
||||
void api.cancelQuery(executionId).catch((error) => appendDebugLog("warn", "[DBX][DataGrid:visible-large-value-preview] cancel failed", error));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisibleLargeValuePreviewHydration(delay = 150) {
|
||||
if (!visibleLargeValuePreviewActive) return;
|
||||
const generation = ++visibleLargeValuePreviewRequestedGeneration;
|
||||
cancelVisibleLargeValuePreviewHydrations();
|
||||
window.clearTimeout(visibleLargeValuePreviewTimer);
|
||||
visibleLargeValuePreviewTimer = window.setTimeout(() => {
|
||||
visibleLargeValuePreviewTimer = 0;
|
||||
void runVisibleLargeValuePreviewHydration(generation);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function pauseVisibleLargeValuePreviewHydration() {
|
||||
visibleLargeValuePreviewActive = false;
|
||||
visibleLargeValuePreviewRequestedGeneration += 1;
|
||||
cancelVisibleLargeValuePreviewHydrations();
|
||||
window.clearTimeout(visibleLargeValuePreviewTimer);
|
||||
visibleLargeValuePreviewTimer = 0;
|
||||
}
|
||||
|
||||
function resumeVisibleLargeValuePreviewHydration() {
|
||||
visibleLargeValuePreviewActive = true;
|
||||
scheduleVisibleLargeValuePreviewHydration();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.result, props.result.rows.length, renderedGridColumns.value.map((column) => column.actualColIdx).join(","), showTranspose.value] as const,
|
||||
() => {
|
||||
nextTick(() => scheduleVisibleLargeValuePreviewHydration());
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onActivated(resumeVisibleLargeValuePreviewHydration);
|
||||
onDeactivated(pauseVisibleLargeValuePreviewHydration);
|
||||
dataGridRuntimeScope.addCleanup(pauseVisibleLargeValuePreviewHydration);
|
||||
|
||||
function chunkLargeValueRequests(requests: LargeValueCellRequest[]): LargeValueCellRequest[][] {
|
||||
const chunks: LargeValueCellRequest[][] = [];
|
||||
let chunk: LargeValueCellRequest[] = [];
|
||||
@@ -4717,6 +4989,7 @@ async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promis
|
||||
const rows = sourceResult.rows.slice();
|
||||
rows[item.sourceIndex!] = row;
|
||||
sourceResult.rows = rows;
|
||||
visibleLargeValuePreviewCaches.get(sourceResult)?.forget(item.sourceIndex!, columnIndex);
|
||||
sourceResult.large_value_cells = sourceResult.large_value_cells?.filter((cell) => cell.row_index !== item.sourceIndex || cell.column_index !== columnIndex);
|
||||
largeValueResolutionVersion.value += 1;
|
||||
clearCellFormatCache();
|
||||
@@ -6816,7 +7089,7 @@ function drawCanvasGrid() {
|
||||
editingCell: editingCell.value,
|
||||
searchMatchKeys: searchMatchSet.value,
|
||||
currentSearchMatch: currentSearchMatch.value,
|
||||
formatCell: (value, columnIndex, row) => formatCellCached(value, columnIndex, largeValueOriginalBytes(row, columnIndex)),
|
||||
formatCell: (value, columnIndex, row) => formatCellCached(visibleLargeValuePreviewValue(row, columnIndex, value), columnIndex, largeValueOriginalBytes(row, columnIndex)),
|
||||
draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"),
|
||||
isRowActive,
|
||||
rowCellsUseSelectionVisual,
|
||||
|
||||
+180
-8
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
buildDataGridContextFilterCondition: vi.fn(async ({ columnName, value }: { columnName: string; value: unknown }) => `\`${columnName}\` = '${String(value)}'`),
|
||||
buildTableSelectSql: vi.fn(async ({ whereInput }: { whereInput?: string }) => `SELECT payload FROM events${whereInput ? ` WHERE ${whereInput}` : ""}`),
|
||||
executeMulti: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,6 +19,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
buildDataGridContextFilterCondition: mocks.buildDataGridContextFilterCondition,
|
||||
buildTableSelectSql: mocks.buildTableSelectSql,
|
||||
executeMulti: mocks.executeMulti,
|
||||
cancelQuery: mocks.cancelQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
@@ -84,10 +86,44 @@ function hydratedResult(id: number, value: string): QueryResult {
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockDeferredFullHydration(hydration: Promise<QueryResult[]>) {
|
||||
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
|
||||
const options = args[5] as { tableDataPreview?: boolean } | undefined;
|
||||
return options?.tableDataPreview ? Promise.resolve([hydratedResult(1, "visible preview")]) : hydration;
|
||||
});
|
||||
}
|
||||
|
||||
function fullHydrationCallCount() {
|
||||
return mocks.executeMulti.mock.calls.filter((call) => !(call[5] as { tableDataPreview?: boolean } | undefined)?.tableDataPreview).length;
|
||||
}
|
||||
|
||||
function visibleHydrationCalls() {
|
||||
return mocks.executeMulti.mock.calls.filter((call) => (call[5] as { tableDataPreview?: boolean } | undefined)?.tableDataPreview);
|
||||
}
|
||||
|
||||
function gridCell(host: HTMLElement): HTMLElement {
|
||||
const cell = host.querySelector<HTMLElement>('[data-row-index="0"] [data-visible-col-index="1"]');
|
||||
if (!cell) throw new Error("Large-value grid cell not found");
|
||||
return cell;
|
||||
}
|
||||
|
||||
function pasteGridCell(host: HTMLElement, value: string) {
|
||||
const cell = gridCell(host);
|
||||
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { getData: () => value } });
|
||||
const gridRoot = host.querySelector<HTMLElement>("[data-grid-root]");
|
||||
if (!gridRoot) throw new Error("Data grid root not found");
|
||||
gridRoot.dispatchEvent(paste);
|
||||
}
|
||||
|
||||
function mountGrid(initialResult = largeValueResult()) {
|
||||
@@ -137,8 +173,19 @@ function mountGrid(initialResult = largeValueResult()) {
|
||||
// Mount once with the production-default Canvas mode so DataGrid finishes
|
||||
// setting up its column-width dependencies, then exercise the DOM grid.
|
||||
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
|
||||
mountedApps.push({ app, host });
|
||||
return { host, onExecuteSql, replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)) };
|
||||
const mounted = { app, host };
|
||||
mountedApps.push(mounted);
|
||||
return {
|
||||
host,
|
||||
onExecuteSql,
|
||||
replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)),
|
||||
unmount: () => {
|
||||
const index = mountedApps.indexOf(mounted);
|
||||
if (index >= 0) mountedApps.splice(index, 1);
|
||||
app.unmount();
|
||||
host.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
@@ -166,6 +213,7 @@ async function startEqualsFilter(host: HTMLElement) {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.cancelQuery.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -179,11 +227,11 @@ afterEach(() => {
|
||||
describe("DataGrid context filter lifecycle", () => {
|
||||
it("keeps the right-click target through menu close and large-value hydration", async () => {
|
||||
const hydration = deferred<QueryResult[]>();
|
||||
mocks.executeMulti.mockReturnValue(hydration.promise);
|
||||
mockDeferredFullHydration(hydration.promise);
|
||||
const { host, onExecuteSql } = mountGrid();
|
||||
|
||||
await startEqualsFilter(host);
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fullHydrationCallCount()).toBe(1));
|
||||
expect(document.querySelector("[data-dbx-context-menu]")).toBeNull();
|
||||
|
||||
hydration.resolve([hydratedResult(1, "full original value")]);
|
||||
@@ -196,11 +244,11 @@ describe("DataGrid context filter lifecycle", () => {
|
||||
|
||||
it("does not apply a completed hydration from a previous result set", async () => {
|
||||
const hydration = deferred<QueryResult[]>();
|
||||
mocks.executeMulti.mockReturnValue(hydration.promise);
|
||||
mockDeferredFullHydration(hydration.promise);
|
||||
const { host, onExecuteSql, replaceResult } = mountGrid();
|
||||
|
||||
await startEqualsFilter(host);
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fullHydrationCallCount()).toBe(1));
|
||||
replaceResult(largeValueResult(2, "new preview"));
|
||||
await settle();
|
||||
hydration.resolve([hydratedResult(1, "old value")]);
|
||||
@@ -224,3 +272,127 @@ describe("DataGrid context filter lifecycle", () => {
|
||||
expect(onExecuteSql).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGrid visible large-value preview lifecycle", () => {
|
||||
it("invalidates a hydrated preview when paste edits the selected cell", async () => {
|
||||
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
|
||||
const options = args[5] as { tableDataPreview?: boolean } | undefined;
|
||||
return Promise.resolve([hydratedResult(1, options?.tableDataPreview ? "visible preview" : "full value")]);
|
||||
});
|
||||
const { host } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("visible preview"));
|
||||
pasteGridCell(host, "edited value");
|
||||
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
|
||||
});
|
||||
|
||||
it("cancels a stale viewport request and starts the newest generation", async () => {
|
||||
const firstHydration = deferred<QueryResult[]>();
|
||||
let visibleRequestCount = 0;
|
||||
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
|
||||
const options = args[5] as { tableDataPreview?: boolean } | undefined;
|
||||
if (!options?.tableDataPreview) return Promise.resolve([hydratedResult(1, "full value")]);
|
||||
visibleRequestCount += 1;
|
||||
return visibleRequestCount === 1 ? firstHydration.promise : Promise.resolve([hydratedResult(1, "latest preview")]);
|
||||
});
|
||||
mocks.cancelQuery.mockImplementation(async () => {
|
||||
firstHydration.reject(new Error("cancelled"));
|
||||
return true;
|
||||
});
|
||||
const { host } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
|
||||
const firstExecutionId = visibleHydrationCalls()[0]?.[4];
|
||||
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
|
||||
if (!scroller) throw new Error("Data grid scroller not found");
|
||||
scroller.scrollTop = 26;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.cancelQuery).toHaveBeenCalledWith(firstExecutionId));
|
||||
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(2));
|
||||
expect(visibleHydrationCalls()[1]?.[4]).not.toBe(firstExecutionId);
|
||||
});
|
||||
|
||||
it("does not cache a visible preview completed after the cell is edited", async () => {
|
||||
const hydration = deferred<QueryResult[]>();
|
||||
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
|
||||
const options = args[5] as { tableDataPreview?: boolean } | undefined;
|
||||
return options?.tableDataPreview ? hydration.promise : Promise.resolve([hydratedResult(1, "full value")]);
|
||||
});
|
||||
const { host } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
|
||||
pasteGridCell(host, "edited during hydration");
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited during hydration"));
|
||||
|
||||
hydration.resolve([hydratedResult(1, "stale visible preview")]);
|
||||
await settle();
|
||||
|
||||
expect(gridCell(host).textContent).toContain("edited during hydration");
|
||||
expect(gridCell(host).textContent).not.toContain("stale visible preview");
|
||||
});
|
||||
|
||||
it("invalidates hydrated previews across undo, scroll, and redo", async () => {
|
||||
let visibleRequestCount = 0;
|
||||
mocks.executeMulti.mockImplementation((...args: unknown[]) => {
|
||||
const options = args[5] as { tableDataPreview?: boolean } | undefined;
|
||||
if (!options?.tableDataPreview) return Promise.resolve([hydratedResult(1, "full value")]);
|
||||
visibleRequestCount += 1;
|
||||
return Promise.resolve([hydratedResult(1, visibleRequestCount === 1 ? "initial visible preview" : "undo visible preview")]);
|
||||
});
|
||||
const { host } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("initial visible preview"));
|
||||
pasteGridCell(host, "edited value");
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
|
||||
|
||||
const gridRoot = host.querySelector<HTMLElement>("[data-grid-root]");
|
||||
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
|
||||
if (!gridRoot || !scroller) throw new Error("Data grid controls not found");
|
||||
gridRoot.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "z" }));
|
||||
await settle();
|
||||
scroller.scrollTop = 26;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("undo visible preview"));
|
||||
gridRoot.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, shiftKey: true, key: "z" }));
|
||||
|
||||
await vi.waitFor(() => expect(gridCell(host).textContent).toContain("edited value"));
|
||||
expect(gridCell(host).textContent).not.toContain("undo visible preview");
|
||||
});
|
||||
|
||||
it("does not start a stale query after deferred SQL construction and scrolling", async () => {
|
||||
const firstBuild = deferred<string>();
|
||||
mocks.buildTableSelectSql.mockImplementationOnce(() => firstBuild.promise).mockResolvedValueOnce("SELECT latest preview");
|
||||
mocks.executeMulti.mockResolvedValue([hydratedResult(1, "latest visible preview")]);
|
||||
const { host } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
|
||||
const scroller = host.querySelector<HTMLElement>(".data-grid-scroller");
|
||||
if (!scroller) throw new Error("Data grid scroller not found");
|
||||
scroller.scrollTop = 26;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(visibleHydrationCalls()).toHaveLength(1));
|
||||
|
||||
firstBuild.resolve("SELECT stale preview");
|
||||
await settle();
|
||||
|
||||
expect(visibleHydrationCalls()).toHaveLength(1);
|
||||
expect(visibleHydrationCalls()[0]?.[2]).toBe("SELECT latest preview");
|
||||
});
|
||||
|
||||
it("does not start a stale query after deferred SQL construction and unmount", async () => {
|
||||
const build = deferred<string>();
|
||||
mocks.buildTableSelectSql.mockImplementationOnce(() => build.promise);
|
||||
const { unmount } = mountGrid();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1));
|
||||
unmount();
|
||||
build.resolve("SELECT stale preview");
|
||||
await settle();
|
||||
|
||||
expect(visibleHydrationCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,11 @@ vi.mock("@/stores/productionSafetyStore", () => ({
|
||||
useProductionSafetyStore: () => ({}),
|
||||
}));
|
||||
|
||||
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[]) {
|
||||
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[], existingRows: CellValue[][] = [], onCellValueChanged?: (rowId: number, columnIndex: number) => void) {
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
|
||||
columns: ["first", "hidden", "last"],
|
||||
rows: [],
|
||||
rows: existingRows,
|
||||
});
|
||||
|
||||
editor = useDataGridEditor({
|
||||
@@ -65,6 +65,7 @@ function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerou
|
||||
pageSize: ref(100),
|
||||
currentPage: ref(1),
|
||||
cacheKey: computed(() => cacheKey),
|
||||
onCellValueChanged,
|
||||
getRowItem: (rowId) => {
|
||||
if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) {
|
||||
return {
|
||||
@@ -77,6 +78,20 @@ function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerou
|
||||
status: "draft",
|
||||
};
|
||||
}
|
||||
if (rowId >= 0) {
|
||||
const row = result.value.rows[rowId];
|
||||
if (!row) return undefined;
|
||||
const changes = editor.dirtyRows.value.get(rowId);
|
||||
return {
|
||||
id: rowId,
|
||||
sourceIndex: rowId,
|
||||
data: row.map((value, columnIndex) => (changes?.has(columnIndex) ? (changes.get(columnIndex) ?? null) : value)),
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: row.map((_, columnIndex) => changes?.has(columnIndex) ?? false),
|
||||
status: changes?.size ? "edited" : "normal",
|
||||
};
|
||||
}
|
||||
const newIndex = -rowId - 1;
|
||||
const row = editor.newRows.value[newIndex];
|
||||
if (!row) return undefined;
|
||||
@@ -210,6 +225,40 @@ describe("useDataGridEditor row deletion confirmation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDataGridEditor cell mutation notifications", () => {
|
||||
it("notifies cache owners for batched paste, NULL, and restore changes", () => {
|
||||
const onCellValueChanged = vi.fn();
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]], onCellValueChanged);
|
||||
|
||||
editor.beginBatch();
|
||||
editor.applyCellValue(0, 1, "pasted");
|
||||
editor.applyCellValue(0, 1, null);
|
||||
editor.commitBatch();
|
||||
editor.restoreCellValue(0, 1);
|
||||
|
||||
expect(onCellValueChanged.mock.calls).toEqual([
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("notifies cache owners when undo and redo replace dirty cells", () => {
|
||||
const onCellValueChanged = vi.fn();
|
||||
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]], onCellValueChanged);
|
||||
|
||||
editor.applyCellValue(0, 1, "edited");
|
||||
onCellValueChanged.mockClear();
|
||||
editor.undoPendingChange();
|
||||
editor.redoPendingChange();
|
||||
|
||||
expect(onCellValueChanged.mock.calls).toEqual([
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" });
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface UseDataGridEditorOptions {
|
||||
cacheKey?: ComputedRef<string | undefined>;
|
||||
/** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */
|
||||
onResultPayloadMutated?: () => void;
|
||||
onCellValueChanged?: (rowId: number, columnIndex: number) => void;
|
||||
prepareFullReload?: () => void;
|
||||
emit: {
|
||||
(event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number): void;
|
||||
@@ -229,6 +230,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
pageSize,
|
||||
currentPage,
|
||||
cacheKey,
|
||||
onCellValueChanged,
|
||||
} = options;
|
||||
|
||||
const editingCell = ref<{ rowId: number; col: number } | null>(null);
|
||||
@@ -405,14 +407,27 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
|
||||
function restorePendingChangesSnapshot(snapshot: PendingChangesHistorySnapshot) {
|
||||
const previousDirtyRows = dirtyRows.value;
|
||||
const restoredDirtyRows = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
|
||||
newRows.value = snapshot.newRows.map((row) => [...row]);
|
||||
restoreNewRowMeta(snapshot.newRowMeta ?? []);
|
||||
quickEntryDraftRow.value = snapshot.quickEntryDraftRow ? [...snapshot.quickEntryDraftRow] : emptyDraftRow();
|
||||
dirtyRows.value = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
|
||||
dirtyRows.value = restoredDirtyRows;
|
||||
deletedRows.value = new Set(snapshot.deletedRows);
|
||||
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
|
||||
queuedAutoSaveChanges.clear();
|
||||
editingCell.value = null;
|
||||
for (const rowIndex of new Set([...previousDirtyRows.keys(), ...restoredDirtyRows.keys()])) {
|
||||
const previousChanges = previousDirtyRows.get(rowIndex);
|
||||
const restoredChanges = restoredDirtyRows.get(rowIndex);
|
||||
for (const columnIndex of new Set([...(previousChanges?.keys() ?? []), ...(restoredChanges?.keys() ?? [])])) {
|
||||
const previousHasValue = previousChanges?.has(columnIndex) ?? false;
|
||||
const restoredHasValue = restoredChanges?.has(columnIndex) ?? false;
|
||||
if (previousHasValue !== restoredHasValue || previousChanges?.get(columnIndex) !== restoredChanges?.get(columnIndex)) {
|
||||
onCellValueChanged?.(rowIndex, columnIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
touchPendingChanges();
|
||||
}
|
||||
|
||||
@@ -838,6 +853,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
if (dataGridQuickEntryEnabled.value && isSaving.value && changed) {
|
||||
rememberQueuedAutoSaveChange(item.sourceIndex, col, newVal);
|
||||
}
|
||||
if (changed) onCellValueChanged?.(rowId, col);
|
||||
return changed ? { changed: true, rowKind: "existing" } : { changed: false, rowKind: "existing" };
|
||||
}
|
||||
|
||||
@@ -956,6 +972,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
dirtyRows.value = new Map(dirtyRows.value);
|
||||
touchPendingChanges();
|
||||
}
|
||||
onCellValueChanged?.(rowId, col);
|
||||
}
|
||||
|
||||
function restoreCellValue(rowId: number, col: number) {
|
||||
@@ -996,6 +1013,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
if (rowChanges.size === 0) dirtyRows.value.delete(item.sourceIndex);
|
||||
dirtyRows.value = new Map(dirtyRows.value);
|
||||
touchPendingChanges();
|
||||
onCellValueChanged?.(rowId, col);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { appendLargeValueCells, canUseTableDataLargeValuePreview, createResultScopedPendingRequests, largeValueCellMap, remapLargeValueCells, TABLE_DATA_CELL_PREVIEW_SIZE, TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import {
|
||||
appendLargeValueCells,
|
||||
canUseTableDataLargeValuePreview,
|
||||
createResultScopedPendingRequests,
|
||||
createResultScopedRowCache,
|
||||
isTableDataVisiblePreviewColumn,
|
||||
largeValueCellMap,
|
||||
remapLargeValueCells,
|
||||
TABLE_DATA_CELL_PREVIEW_SIZE,
|
||||
TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES,
|
||||
tableDataLargeValuePreviewOptions,
|
||||
tableDataVisiblePreviewContentBytes,
|
||||
tableDataVisiblePreviewRowRange,
|
||||
} from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { buildDataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
|
||||
@@ -66,6 +79,59 @@ describe("data grid large-value metadata", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects the visible rows plus a bounded viewport buffer", () => {
|
||||
expect(tableDataVisiblePreviewRowRange(26 * 50, 26 * 20, 26, 10_000)).toEqual({ start: 30, end: 90 });
|
||||
expect(tableDataVisiblePreviewRowRange(0, 26 * 20, 26, 10_000)).toEqual({ start: 0, end: 60 });
|
||||
expect(tableDataVisiblePreviewRowRange(26 * 9_990, 26 * 20, 26, 10_000)).toEqual({ start: 9_940, end: 10_000 });
|
||||
expect(tableDataVisiblePreviewRowRange(0, 0, 26, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates only textual large-value types in the visible area", () => {
|
||||
expect(isTableDataVisiblePreviewColumn("mysql", "longtext")).toBe(true);
|
||||
expect(isTableDataVisiblePreviewColumn("mysql", "varchar(8000)")).toBe(true);
|
||||
expect(isTableDataVisiblePreviewColumn("mysql", "longblob")).toBe(false);
|
||||
expect(isTableDataVisiblePreviewColumn("postgres", "character varying(8000)")).toBe(true);
|
||||
expect(isTableDataVisiblePreviewColumn("postgres", "jsonb")).toBe(true);
|
||||
expect(isTableDataVisiblePreviewColumn("postgres", "text[]")).toBe(false);
|
||||
expect(isTableDataVisiblePreviewColumn("postgres", "vector(1536)")).toBe(false);
|
||||
expect(isTableDataVisiblePreviewColumn("oracle", "clob")).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts old preview rows while retaining the active viewport", () => {
|
||||
const cache = createResultScopedRowCache<string>(2);
|
||||
cache.remember(1, 2, "one");
|
||||
cache.remember(2, 2, "two");
|
||||
cache.remember(3, 2, "three");
|
||||
|
||||
expect(cache.get(1, 2)).toBe("one");
|
||||
expect(cache.evict(new Set([1]))).toEqual([{ rowIndex: 2, columnIndex: 2, value: "two" }]);
|
||||
expect(cache.has(1, 2)).toBe(true);
|
||||
expect(cache.has(2, 2)).toBe(false);
|
||||
expect(cache.has(3, 2)).toBe(true);
|
||||
|
||||
cache.forget(1, 2);
|
||||
expect(cache.has(1, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds cached preview content while preserving the newest active rows", () => {
|
||||
const cache = createResultScopedRowCache<string>(10, { maxBytes: 6, sizeOf: (value) => value.length });
|
||||
cache.remember(1, 1, "one");
|
||||
cache.remember(2, 1, "two");
|
||||
cache.remember(3, 1, "four");
|
||||
|
||||
expect(cache.evict(new Set([3]))).toEqual([
|
||||
{ rowIndex: 1, columnIndex: 1, value: "one" },
|
||||
{ rowIndex: 2, columnIndex: 1, value: "two" },
|
||||
]);
|
||||
expect(cache.has(3, 1)).toBe(true);
|
||||
|
||||
const oversizedActive = createResultScopedRowCache<string>(10, { maxBytes: 3, sizeOf: (value) => value.length });
|
||||
oversizedActive.remember(1, 1, "four");
|
||||
expect(oversizedActive.evict(new Set([1]))).toEqual([{ rowIndex: 1, columnIndex: 1, value: "four" }]);
|
||||
expect(oversizedActive.has(1, 1)).toBe(false);
|
||||
expect(tableDataVisiblePreviewContentBytes("🙂")).toBe(4);
|
||||
});
|
||||
|
||||
it("uses the active MySQL page budget to select bounded preview columns", () => {
|
||||
const columns = [column("id", "int", true), column("image_mime", "varchar(64)"), column("image_data", "longblob"), column("image_url", "varchar(512)"), column("image_note", "varchar(255)")];
|
||||
|
||||
|
||||
@@ -1,16 +1,143 @@
|
||||
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
|
||||
export const TABLE_DATA_RESULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
export const TABLE_DATA_CELL_PREVIEW_MIN_SIZE = 1;
|
||||
export const TABLE_DATA_CELL_PREVIEW_SIZE = 8 * 1024;
|
||||
export const TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES = 24 * 1024 * 1024;
|
||||
export const TABLE_DATA_TEXT_SERIALIZED_BYTES_PER_CHARACTER = 6;
|
||||
export const TABLE_DATA_VISIBLE_PREVIEW_SIZE = 512;
|
||||
export const TABLE_DATA_VISIBLE_PREVIEW_MAX_ROWS = 100;
|
||||
export const TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS = 2_000;
|
||||
export const TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const TABLE_DATA_LARGE_VALUE_MARKER_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
|
||||
|
||||
export interface TableDataVisiblePreviewRowRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ResultScopedRowCache<T> {
|
||||
has(rowIndex: number, columnIndex: number): boolean;
|
||||
get(rowIndex: number, columnIndex: number): T | undefined;
|
||||
touch(rowIndex: number): void;
|
||||
remember(rowIndex: number, columnIndex: number, value: T): void;
|
||||
forget(rowIndex: number, columnIndex: number): void;
|
||||
evict(protectedRows?: ReadonlySet<number>): Array<{ rowIndex: number; columnIndex: number; value: T }>;
|
||||
}
|
||||
|
||||
export function tableDataVisiblePreviewRowRange(scrollTop: number, viewportHeight: number, rowHeight: number, rowCount: number, maxRows = TABLE_DATA_VISIBLE_PREVIEW_MAX_ROWS): TableDataVisiblePreviewRowRange | null {
|
||||
if (rowHeight <= 0 || rowCount <= 0 || maxRows <= 0) return null;
|
||||
const firstVisible = Math.max(0, Math.min(rowCount - 1, Math.floor(Math.max(0, scrollTop) / rowHeight)));
|
||||
const visibleRows = Math.max(1, Math.ceil(Math.max(rowHeight, viewportHeight) / rowHeight));
|
||||
const targetRows = Math.min(rowCount, maxRows, visibleRows * 3);
|
||||
const rowsBefore = Math.floor(Math.max(0, targetRows - visibleRows) / 2);
|
||||
let start = Math.max(0, firstVisible - rowsBefore);
|
||||
const end = Math.min(rowCount, start + targetRows);
|
||||
start = Math.max(0, end - targetRows);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function tableDataVisiblePreviewContentBytes(value: CellValue): number {
|
||||
if (typeof value === "string") return value.length * 2;
|
||||
if (typeof value === "number") return 8;
|
||||
if (typeof value === "boolean") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function createResultScopedRowCache<T>(maxRows: number, options: { maxBytes?: number; sizeOf?: (value: T) => number } = {}): ResultScopedRowCache<T> {
|
||||
const rows = new Map<number, Map<number, T>>();
|
||||
const rowBytes = new Map<number, number>();
|
||||
const maxCachedRows = Math.max(0, maxRows);
|
||||
const maxCachedBytes = Math.max(0, options.maxBytes ?? Number.POSITIVE_INFINITY);
|
||||
const sizeOf = options.sizeOf ?? (() => 0);
|
||||
let cachedBytes = 0;
|
||||
|
||||
function touch(rowIndex: number, values: Map<number, T>) {
|
||||
rows.delete(rowIndex);
|
||||
rows.set(rowIndex, values);
|
||||
}
|
||||
|
||||
function removeRow(rowIndex: number, values: Map<number, T>, evicted: Array<{ rowIndex: number; columnIndex: number; value: T }>) {
|
||||
rows.delete(rowIndex);
|
||||
cachedBytes = Math.max(0, cachedBytes - (rowBytes.get(rowIndex) ?? 0));
|
||||
rowBytes.delete(rowIndex);
|
||||
for (const [columnIndex, value] of values) evicted.push({ rowIndex, columnIndex, value });
|
||||
}
|
||||
|
||||
function exceedsLimit(): boolean {
|
||||
return rows.size > maxCachedRows || cachedBytes > maxCachedBytes;
|
||||
}
|
||||
|
||||
return {
|
||||
has(rowIndex, columnIndex) {
|
||||
return rows.get(rowIndex)?.has(columnIndex) ?? false;
|
||||
},
|
||||
get(rowIndex, columnIndex) {
|
||||
return rows.get(rowIndex)?.get(columnIndex);
|
||||
},
|
||||
touch(rowIndex) {
|
||||
const values = rows.get(rowIndex);
|
||||
if (values) touch(rowIndex, values);
|
||||
},
|
||||
remember(rowIndex, columnIndex, value) {
|
||||
const values = rows.get(rowIndex) ?? new Map<number, T>();
|
||||
if (!values.has(columnIndex)) {
|
||||
values.set(columnIndex, value);
|
||||
const valueBytes = Math.max(0, sizeOf(value));
|
||||
cachedBytes += valueBytes;
|
||||
rowBytes.set(rowIndex, (rowBytes.get(rowIndex) ?? 0) + valueBytes);
|
||||
}
|
||||
touch(rowIndex, values);
|
||||
},
|
||||
forget(rowIndex, columnIndex) {
|
||||
const values = rows.get(rowIndex);
|
||||
if (!values) return;
|
||||
const value = values.get(columnIndex);
|
||||
if (value === undefined && !values.has(columnIndex)) return;
|
||||
values.delete(columnIndex);
|
||||
const valueBytes = Math.max(0, sizeOf(value as T));
|
||||
cachedBytes = Math.max(0, cachedBytes - valueBytes);
|
||||
const remainingRowBytes = Math.max(0, (rowBytes.get(rowIndex) ?? 0) - valueBytes);
|
||||
if (values.size === 0) {
|
||||
rows.delete(rowIndex);
|
||||
rowBytes.delete(rowIndex);
|
||||
} else {
|
||||
rowBytes.set(rowIndex, remainingRowBytes);
|
||||
}
|
||||
},
|
||||
evict(protectedRows = new Set<number>()) {
|
||||
const evicted: Array<{ rowIndex: number; columnIndex: number; value: T }> = [];
|
||||
for (const [rowIndex, values] of rows) {
|
||||
if (!exceedsLimit()) break;
|
||||
if (protectedRows.has(rowIndex)) continue;
|
||||
removeRow(rowIndex, values, evicted);
|
||||
}
|
||||
for (const [rowIndex, values] of rows) {
|
||||
if (!exceedsLimit()) break;
|
||||
removeRow(rowIndex, values, evicted);
|
||||
}
|
||||
return evicted;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedDataTypeBase(dataType: string): string {
|
||||
return dataType.trim().split(/[([]/, 1)[0]?.trim().toLocaleLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export function isTableDataVisiblePreviewColumn(databaseType: DatabaseType | undefined, dataType: string): boolean {
|
||||
const normalized = dataType.trim().toLocaleLowerCase();
|
||||
const base = normalizedDataTypeBase(dataType);
|
||||
if (databaseType === "mysql") {
|
||||
return base === "text" || base === "tinytext" || base === "mediumtext" || base === "longtext" || base === "varchar" || base === "json";
|
||||
}
|
||||
if (databaseType === "postgres") {
|
||||
return !normalized.includes("[") && (base === "char" || base === "character" || base === "varchar" || base === "text" || base === "citext" || base === "name" || base === "xml" || base === "json" || base === "jsonb" || base === "tsvector" || normalized.startsWith("character varying"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function declaredDataTypeLength(dataType: string): number | undefined {
|
||||
const match = dataType.match(/\(\s*(\d+)/);
|
||||
if (!match?.[1]) return undefined;
|
||||
|
||||
Reference in new issue
Block a user