From e50bd261419a0c87d6c3dda4f122670c1d9086fd Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 21 Aug 2026 19:05:44 +0800 Subject: [PATCH] feat(grid): load large-value previews by viewport --- apps/desktop/src/components/grid/DataGrid.vue | 281 +++++++++++++++++- .../DataGridContextExtractor.spec.ts | 188 +++++++++++- .../__tests__/useDataGridEditor.spec.ts | 53 +++- .../src/composables/useDataGridEditor.ts | 20 +- .../dataGrid/dataGridLargeValues.spec.ts | 68 ++++- .../src/lib/dataGrid/dataGridLargeValues.ts | 127 ++++++++ 6 files changed, 721 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0192aa6e02..7b334f962c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(); const largeValueCellsByKey = computed(() => largeValueCellMap(props.result)); +type VisibleLargeValuePreviewRequest = { + item: RowItem; + sourceIndex: number; + initialValues: Map; +}; +const visibleLargeValuePreviewCaches = new WeakMap>(); +const visibleLargeValuePreviewCellRevisions = new WeakMap>(); +const failedVisibleLargeValuePreviewResults = new WeakSet(); +const visibleLargeValuePreviewVersion = ref(0); +let visibleLargeValuePreviewTimer = 0; +let visibleLargeValuePreviewRequestedGeneration = 0; +let visibleLargeValuePreviewActive = true; +const visibleLargeValuePreviewExecutionIds = new Map(); 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 column.toLocaleLowerCase() === normalized); } +function visibleLargeValuePreviewCache(result: QueryResult): ResultScopedRowCache { + 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(); + 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 | 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, protectedRows: ReadonlySet): 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(); + const requests = new Map(); + 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(); + 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, diff --git a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts index fe84a5609b..2c82571a68 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts @@ -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() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, resolve, reject }; +} + +function mockDeferredFullHydration(hydration: Promise) { + 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('[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("[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(); - 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(); - 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(); + 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(".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(); + 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("[data-grid-root]"); + const scroller = host.querySelector(".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(); + 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(".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(); + 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); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index d9b487c192..679fb3ce1a 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -30,11 +30,11 @@ vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}), })); -function createEditor(sourceColumns?: Array, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[]) { +function createEditor(sourceColumns?: Array, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[], existingRows: CellValue[][] = [], onCellValueChanged?: (rowId: number, columnIndex: number) => void) { let editor: ReturnType; const result = ref<{ columns: string[]; rows: CellValue[][] }>({ columns: ["first", "hidden", "last"], - rows: [], + rows: existingRows, }); editor = useDataGridEditor({ @@ -65,6 +65,7 @@ function createEditor(sourceColumns?: Array, 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, 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" }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 6647b5e40b..09b48cdc99 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -117,6 +117,7 @@ export interface UseDataGridEditorOptions { cacheKey?: ComputedRef; /** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */ 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() { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts index b45508d115..11ddac4336 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts @@ -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(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(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(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)")]; diff --git a/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts b/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts index a40f5dd131..4d54b713ca 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts @@ -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 { + 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): 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(maxRows: number, options: { maxBytes?: number; sizeOf?: (value: T) => number } = {}): ResultScopedRowCache { + const rows = new Map>(); + const rowBytes = new Map(); + 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) { + rows.delete(rowIndex); + rows.set(rowIndex, values); + } + + function removeRow(rowIndex: number, values: Map, 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(); + 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()) { + 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;