From 03cfeba8f7d75643c3b7869bc43a6e9dfe0ad373 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 9 Sep 2026 00:31:02 +0800 Subject: [PATCH] refactor(grid): extract large value workflow --- apps/desktop/src/components/grid/DataGrid.vue | 542 ++--------------- .../DataGridCellDetailSelection.spec.ts | 4 +- .../DataGridDeleteRowDetails.spec.ts | 3 +- .../DataGridLargeValueReloadSql.spec.ts | 11 +- .../src/composables/useDataGridLargeValues.ts | 574 ++++++++++++++++++ 5 files changed, 634 insertions(+), 500 deletions(-) create mode 100644 apps/desktop/src/composables/useDataGridLargeValues.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 488e3ff44d..4bb52f469f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -208,20 +208,7 @@ import { type DataGridInexactTotalRowCountMode, } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; -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 { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { dataGridBottomScrollTop, dataGridInfiniteScrollAppendCompletion, @@ -359,11 +346,11 @@ import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote, dataG import { isMacOS } from "@/lib/backend/platform"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; -import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnFormatter"; import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders"; import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters"; +import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues"; const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue")); const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue")); @@ -377,6 +364,15 @@ const DataGridInsertRowsDialog = defineAsyncComponent(() => import("@/components const ExportProgressDialog = defineAsyncComponent(() => import("@/components/export/ExportProgressDialog.vue")); const FORMATTED_JSON_EDIT_WARNING_COUNT_STORAGE_KEY = "dbx-cell-detail-formatted-json-edit-warning-count"; const FORMATTED_JSON_EDIT_WARNING_MAX_COUNT = 3; +let largeValueRuntime: ReturnType; + +function scheduleVisibleLargeValuePreviewHydration(delay = 150) { + largeValueRuntime.scheduleVisibleLargeValuePreviewHydration(delay); +} + +async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promise { + return largeValueRuntime.hydrateLargeValueCell(rowId, columnIndex); +} const { t } = useI18n(); const slots = useSlots(); @@ -3507,7 +3503,7 @@ const editor = useDataGridEditor({ cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey), onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result), refreshSavedRows, - onCellValueChanged: invalidateVisibleLargeValuePreviewCell, + onCellValueChanged: (rowId, columnIndex) => largeValueRuntime.invalidateVisibleLargeValuePreviewCell(rowId, columnIndex), prepareFullReload, emit, }); @@ -4527,483 +4523,43 @@ const visibleDisplayItems = computed(() => })), ); -type ResolvedLargeValueCells = Map>; -type LargeValueCellRequest = { - item: RowItem; - sourceIndex: number; - columnIndex: number; - originalBytes: number; -}; - -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; - return largeValueCellsByKey.value.has(largeValueCellKey(item.sourceIndex, columnIndex)); -} - -function largeValueOriginalBytes(item: Pick | undefined, columnIndex: number): number | undefined { - if (resolvedDatabaseType.value !== "mysql" || !item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return undefined; - return largeValueCellsByKey.value.get(largeValueCellKey(item.sourceIndex, columnIndex))?.original_bytes; -} - -function formatGridItemCell(item: RowItem, columnIndex: number): string { - void visibleLargeValuePreviewVersion.value; - return formatCellCached(visibleLargeValuePreviewValue(item, columnIndex, item.data[columnIndex] ?? null), columnIndex, largeValueOriginalBytes(item, columnIndex)); -} - -function formatGridItemCellForConfirmation(item: RowItem, columnIndex: number): string { - return formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false); -} - -function normalizedLargeValueIdentityPart(value: CellValue): string { - if (value === null) return "null"; - return `${typeof value}:${JSON.stringify(value)}`; -} - -function largeValueIdentityKey(row: readonly CellValue[], indexes: readonly number[]): string { - return indexes.map((index) => normalizedLargeValueIdentityPart(row[index] ?? null)).join("\u001f"); -} - -function largeValueSourceColumnIndex(columnName: string): number { - const normalized = columnName.toLocaleLowerCase(); - return resultSourceColumns.value.findIndex((column) => 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, - includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), - }); - 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[] = []; - let bytes = 0; - for (const request of requests) { - if (chunk.length > 0 && (chunk.length >= LARGE_VALUE_FETCH_MAX_ROWS || bytes + request.originalBytes > LARGE_VALUE_FETCH_TARGET_BYTES)) { - chunks.push(chunk); - chunk = []; - bytes = 0; - } - chunk.push(request); - bytes += request.originalBytes; - } - if (chunk.length > 0) chunks.push(chunk); - return chunks; -} - -async function largeValueRowPredicate(item: RowItem, primaryKeyIndexes: number[]): Promise { - const originalRow = item.sourceIndex === undefined ? undefined : props.result.rows[item.sourceIndex]; - if (!originalRow) throw new Error(t("grid.largeValueRowUnavailable")); - const tableMeta = props.tableMeta!; - const conditions = await Promise.all( - tableMeta.primaryKeys.map((columnName, index) => - buildDataGridContextFilterCondition({ - databaseType: resolvedDatabaseType.value, - identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId), - columnName, - columnInfo: tableMeta.columns.find((column) => column.name.toLocaleLowerCase() === columnName.toLocaleLowerCase()), - mode: "equals", - value: originalRow[primaryKeyIndexes[index]!] ?? null, - }), - ), - ); - if (conditions.some((condition) => !condition)) throw new Error(t("grid.largeValueRowUnavailable")); - return conditions.map((condition) => `(${condition})`).join(" AND "); -} - -async function fetchLargeValueRequestChunk(columnIndex: number, requests: LargeValueCellRequest[], primaryKeyIndexes: number[], resolved: ResolvedLargeValueCells) { - const tableMeta = props.tableMeta!; - const sourceColumn = resultSourceColumns.value[columnIndex]; - if (!sourceColumn) throw new Error(t("grid.largeValueColumnUnavailable")); - const predicates = await Promise.all(requests.map((request) => largeValueRowPredicate(request.item, primaryKeyIndexes))); - const selectedColumns = [...new Set([...tableMeta.primaryKeys, sourceColumn])]; - 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, - includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, - primaryKeys: tableMeta.primaryKeys, - whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "), - limit: requests.length, - offset: 0, - includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), - }); - const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined; - const results = await api.executeMulti(props.connectionId!, props.executionDatabase ?? props.database ?? "", sql, undefined, uuid(), { - maxRows: requests.length, - fetchSize: requests.length, - timeoutSecs: queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs), - }); - 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 resultValueIndex = resultColumnIndexes[selectedColumns.indexOf(sourceColumn)]; - const valuesByIdentity = new Map(result.rows.map((row) => [largeValueIdentityKey(row, resultPrimaryKeyIndexes), row[resultValueIndex!] ?? null])); - for (const request of requests) { - const originalRow = props.result.rows[request.sourceIndex]; - const identity = originalRow ? largeValueIdentityKey(originalRow, primaryKeyIndexes) : ""; - if (!valuesByIdentity.has(identity)) throw new Error(t("grid.largeValueRowUnavailable")); - const rowValues = resolved.get(request.item.id) ?? new Map(); - rowValues.set(columnIndex, valuesByIdentity.get(identity) ?? null); - resolved.set(request.item.id, rowValues); - } -} - -async function resolveLargeValueCells(rowIds: number[], columnIndexes: number[]): Promise { - const resolved: ResolvedLargeValueCells = new Map(); - const requestedColumns = new Set(columnIndexes); - const requestsByColumn = new Map(); - for (const rowId of new Set(rowIds)) { - const item = getRowItem(rowId); - if (!item || item.sourceIndex === undefined) continue; - for (const columnIndex of requestedColumns) { - if (!isLargeValuePreview(item, columnIndex)) continue; - const metadata = largeValueCellsByKey.value.get(largeValueCellKey(item.sourceIndex, columnIndex)); - if (!metadata) continue; - const requests = requestsByColumn.get(columnIndex) ?? []; - requests.push({ item, sourceIndex: item.sourceIndex, columnIndex, originalBytes: metadata.original_bytes }); - requestsByColumn.set(columnIndex, requests); - } - } - if (requestsByColumn.size === 0) return resolved; - if ((resolvedDatabaseType.value !== "mysql" && resolvedDatabaseType.value !== "postgres" && resolvedDatabaseType.value !== "oracle") || !props.connectionId || !props.tableMeta?.tableName || props.tableMeta.primaryKeys.length === 0) { - throw new Error(t("grid.largeValueNeedsStableKey")); - } - const primaryKeyIndexes = props.tableMeta.primaryKeys.map(largeValueSourceColumnIndex); - if (primaryKeyIndexes.some((index) => index < 0)) throw new Error(t("grid.largeValueNeedsStableKey")); - - for (const [columnIndex, requests] of requestsByColumn) { - for (const chunk of chunkLargeValueRequests(requests)) { - await fetchLargeValueRequestChunk(columnIndex, chunk, primaryKeyIndexes, resolved); - } - } - return resolved; -} - -function reportLargeValueLoadError(error: unknown) { - toast(t("grid.largeValueLoadFailedWithMessage", { message: translateBackendError(t, error) }), 5000); -} - -async function cloneRow(rowId: number) { - try { - const resolved = await resolveLargeValueCells( - [rowId], - props.result.columns.map((_, index) => index), - ); - cloneEditorRow(rowId, resolved.get(rowId)); - } catch (error) { - reportLargeValueLoadError(error); - } -} - -async function cloneRows(rowIds: number[]) { - try { - const resolved = await resolveLargeValueCells( - rowIds, - props.result.columns.map((_, index) => index), - ); - cloneEditorRows(rowIds, resolved); - } catch (error) { - reportLargeValueLoadError(error); - } -} - -async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promise { - const item = getRowItem(rowId); - if (!isLargeValuePreview(item, columnIndex) || item?.sourceIndex === undefined) return true; - const sourceResult = props.result; - const hydrationKey = largeValueCellKey(item.sourceIndex, columnIndex); - const operation = dataGridResultLifecycle.beginOperation(); - return pendingLargeValueHydrations.run(hydrationKey, sourceResult, async () => { - try { - const resolved = await resolveLargeValueCells([rowId], [columnIndex]); - if (!dataGridResultLifecycle.isCurrent(operation) || props.result !== sourceResult) return false; - const value = resolved.get(rowId)?.get(columnIndex); - if (value === undefined && !resolved.get(rowId)?.has(columnIndex)) return false; - const row = [...(sourceResult.rows[item.sourceIndex!] ?? [])]; - row[columnIndex] = value ?? null; - 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(); - queryStore.invalidateResultEstimateForPayload(sourceResult); - return true; - } catch (error) { - if (dataGridResultLifecycle.isCurrent(operation) && props.result === sourceResult) reportLargeValueLoadError(error); - return false; - } - }); -} +const largeValueRuntimeInstance = useDataGridLargeValues({ + result: computed(() => props.result), + tableMeta: computed(() => props.tableMeta), + databaseType: resolvedDatabaseType, + connectionId: computed(() => props.connectionId), + executionDatabase: computed(() => props.executionDatabase ?? props.database ?? ""), + resultSourceColumns, + allColumnTypes, + renderedGridColumns, + showTranspose, + displayRowCount, + gridScrollerElement, + displayItemAt, + getRowItem, + tableColumnForGridColumn, + formatCell, + formatCellCached, + scheduleCanvasDraw, + clearCellFormatCache, + invalidateResultEstimate: (result) => queryStore.invalidateResultEstimateForPayload(result), + connectionIdentifierQuote: (connectionId) => (connectionId ? connectionStore.connectionIdentifierQuote(connectionId) : undefined), + getConnectionConfig: (connectionId) => connectionStore.getConfig(connectionId), + includeDatabaseName: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName), + globalQueryTimeoutSecs: computed(() => settingsStore.editorSettings.globalQueryTimeoutSecs), + resultLifecycle: dataGridResultLifecycle, + largeValueResolutionVersion, + runtimeScope: dataGridRuntimeScope, + uuid, + translate: (key, params) => (params ? t(key, params) : t(key)), + translateBackendError: (error) => translateBackendError(t, error), + appendDebugLog, + toast, + cloneRow: (rowId, resolved) => cloneEditorRow(rowId, resolved), + cloneRows: cloneEditorRows, +}); +largeValueRuntime = largeValueRuntimeInstance; +const { isLargeValuePreview, largeValueOriginalBytes, formatGridItemCell, formatGridItemCellForConfirmation, visibleLargeValuePreviewValue, resolveLargeValueCells, reportLargeValueLoadError, cloneRow, cloneRows } = largeValueRuntimeInstance; const exportContextCell = computed(() => { if (!contextCell.value) return null; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts index 2f9edd7dc5..b307691fc0 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); +const largeValueSource = readFileSync(new URL("../../../composables/useDataGridLargeValues.ts", import.meta.url), "utf8"); describe("DataGrid cell detail selection", () => { it("keeps Canvas hover state while the renderer swaps drawing surfaces", () => { @@ -46,7 +47,8 @@ describe("DataGrid cell detail selection", () => { }); it("hydrates bounded large-value previews for every cell detail target", () => { - expect(dataGridSource).toMatch(/function hydrateCellDetailTarget[\s\S]*?isLargeValuePreview[\s\S]*?hydrateLargeValueCell/); + expect(largeValueSource).toContain("function isLargeValuePreview"); + expect(dataGridSource).toContain("function hydrateLargeValueCell"); expect(dataGridSource).toMatch(/showCellDetails[\s\S]*?hydrateCellDetailTarget\(detailCell\.value\)/); expect(dataGridSource).toMatch(/openCellDetailDialog[\s\S]*?hydrateCellDetailTarget\(cellDetailDialogTarget\.value\)/); }); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridDeleteRowDetails.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridDeleteRowDetails.spec.ts index 8227cacaed..369a78fd4d 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridDeleteRowDetails.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridDeleteRowDetails.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); +const largeValueSource = readFileSync(new URL("../../../composables/useDataGridLargeValues.ts", import.meta.url), "utf8"); describe("DataGrid delete row confirmation details", () => { it("still wires deleteRowDetails into the delete-row DangerConfirmDialog", () => { @@ -22,7 +23,7 @@ describe("DataGrid delete row confirmation details", () => { expect(detailsStart).toBeGreaterThanOrEqual(0); expect(detailsEnd).toBeGreaterThan(detailsStart); expect(dataGridSource.slice(detailsStart, detailsEnd)).toContain("formatGridItemCellForConfirmation"); - expect(dataGridSource).toContain("formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false)"); + expect(largeValueSource).toContain("options.formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false)"); }); it("keeps close-on-confirm disabled so confirmDeleteRow runs before the dialog auto-closes", () => { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts index 015da4ca08..ba3c803155 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts @@ -2,13 +2,14 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); +const largeValueSource = readFileSync(new URL("../../../composables/useDataGridLargeValues.ts", import.meta.url), "utf8"); function functionSource(name: string, nextName: string): string { - const start = dataGridSource.indexOf(`function ${name}`); - const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`)); + const start = largeValueSource.indexOf(`function ${name}`); + const relativeEnd = largeValueSource.slice(start).search(new RegExp(`\\n\\s*(?:async\\s+)?function\\s+${nextName}\\b`)); expect(start).toBeGreaterThanOrEqual(0); expect(relativeEnd).toBeGreaterThan(0); - return dataGridSource.slice(start, start + relativeEnd); + return largeValueSource.slice(start, start + relativeEnd); } describe("DataGrid large-value reload SQL", () => { @@ -18,12 +19,12 @@ describe("DataGrid large-value reload SQL", () => { // Keyless Oracle tables address rows via the hidden __DBX_ROWID alias; the // reload must opt into the ROWIDTOCHAR inline view so the generated SQL // never references __DBX_ROWID as a base-table column (ORA-00904). - expect(fetchChunkSource).toContain("includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); + expect(fetchChunkSource).toContain("includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); }); it("keeps the visible-preview hydration consistent with the synthetic key", () => { const hydrateSource = functionSource("hydrateVisibleLargeValuePreviews", "runVisibleLargeValuePreviewHydration"); - expect(hydrateSource).toContain("includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); + expect(hydrateSource).toContain("includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); }); }); diff --git a/apps/desktop/src/composables/useDataGridLargeValues.ts b/apps/desktop/src/composables/useDataGridLargeValues.ts new file mode 100644 index 0000000000..7892be3bf4 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridLargeValues.ts @@ -0,0 +1,574 @@ +import { computed, nextTick, onActivated, onDeactivated, ref, watch, type ComputedRef, type Ref } from "vue"; +import * as api from "@/lib/backend/api"; +import type { CellValue } from "@/lib/dataGrid/cellValue"; +import { buildDataGridContextFilterCondition } from "@/lib/dataGrid/dataGridSql"; +import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; +import { shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing"; +import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; +import type { ColumnInfo, ConnectionConfig, DatabaseType, QueryResult } from "@/types/database"; +import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; +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, + tableDataVisiblePreviewContentBytes, + tableDataVisiblePreviewRowRange, + type ResultScopedRowCache, +} from "@/lib/dataGrid/dataGridLargeValues"; +import { CANVAS_DATA_GRID_ROW_HEIGHT } from "@/lib/dataGrid/canvasDataGridRenderer"; + +interface LargeValueRowItem { + id: number; + sourceIndex?: number; + data: CellValue[]; + isNew: boolean; + isDraft?: boolean; + isDeleted: boolean; + isDirtyCol: boolean[]; +} + +interface LargeValueTableMeta { + catalog?: string; + database?: string; + schema?: string; + tableName: string; + tableType?: string; + columns: ColumnInfo[]; + primaryKeys: string[]; +} + +export interface UseDataGridLargeValuesOptions { + result: ComputedRef; + tableMeta: ComputedRef; + databaseType: ComputedRef; + connectionId: ComputedRef; + executionDatabase: ComputedRef; + resultSourceColumns: ComputedRef; + allColumnTypes: ComputedRef>; + renderedGridColumns: ComputedRef>; + showTranspose: Ref; + displayRowCount: ComputedRef; + gridScrollerElement: () => HTMLElement | null; + displayItemAt: (displayIndex: number) => LargeValueRowItem | undefined; + getRowItem: (rowId: number) => LargeValueRowItem | undefined; + tableColumnForGridColumn: (columnIndex: number) => ColumnInfo | undefined; + formatCell: (value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay?: boolean) => string; + formatCellCached: (value: CellValue, columnIndex?: number, originalBytes?: number) => string; + scheduleCanvasDraw: () => void; + clearCellFormatCache: () => void; + invalidateResultEstimate: (result: QueryResult) => void; + connectionIdentifierQuote: (connectionId?: string) => string | undefined; + getConnectionConfig: (connectionId: string) => ConnectionConfig | undefined; + includeDatabaseName: ComputedRef; + globalQueryTimeoutSecs: ComputedRef; + resultLifecycle: { beginOperation: () => number; isCurrent: (operation: number) => boolean }; + largeValueResolutionVersion: Ref; + runtimeScope: DataGridRuntimeScope; + uuid: () => string; + translate: (key: string, params?: Record) => string; + translateBackendError: (error: unknown) => string; + appendDebugLog: (level: "info" | "warn" | "error", message: string, payload?: unknown) => void; + toast: (message: string, duration?: number) => void; + cloneRow: (rowId: number, resolved?: Map) => void; + cloneRows: (rowIds: number[], resolved: Map>) => void; +} + +type ResolvedLargeValueCells = Map>; +type LargeValueCellRequest = { + item: LargeValueRowItem; + sourceIndex: number; + columnIndex: number; + originalBytes: number; +}; + +export function useDataGridLargeValues(options: UseDataGridLargeValuesOptions) { + const LARGE_VALUE_FETCH_MAX_ROWS = 200; + const LARGE_VALUE_FETCH_TARGET_BYTES = 64 * 1024 * 1024; + const pendingLargeValueHydrations = createResultScopedPendingRequests(); + const largeValueCellsByKey = computed(() => largeValueCellMap(options.result.value)); + type VisibleLargeValuePreviewRequest = { + item: LargeValueRowItem; + 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: LargeValueRowItem | undefined, columnIndex: number): boolean { + if (!item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return false; + return largeValueCellsByKey.value.has(largeValueCellKey(item.sourceIndex, columnIndex)); + } + + function largeValueOriginalBytes(item: Pick | undefined, columnIndex: number): number | undefined { + if (options.databaseType.value !== "mysql" || !item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return undefined; + return largeValueCellsByKey.value.get(largeValueCellKey(item.sourceIndex, columnIndex))?.original_bytes; + } + + function formatGridItemCell(item: LargeValueRowItem, columnIndex: number): string { + void visibleLargeValuePreviewVersion.value; + return options.formatCellCached(visibleLargeValuePreviewValue(item, columnIndex, item.data[columnIndex] ?? null), columnIndex, largeValueOriginalBytes(item, columnIndex)); + } + + function formatGridItemCellForConfirmation(item: LargeValueRowItem, columnIndex: number): string { + return options.formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false); + } + + function normalizedLargeValueIdentityPart(value: CellValue): string { + if (value === null) return "null"; + return `${typeof value}:${JSON.stringify(value)}`; + } + + function largeValueIdentityKey(row: readonly CellValue[], indexes: readonly number[]): string { + return indexes.map((index) => normalizedLargeValueIdentityPart(row[index] ?? null)).join("\u001f"); + } + + function largeValueSourceColumnIndex(columnName: string): number { + const normalized = columnName.toLocaleLowerCase(); + return options.resultSourceColumns.value.findIndex((column) => 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 = options.getRowItem(rowId); + const sourceIndex = item?.sourceIndex ?? (rowId >= 0 ? rowId : undefined); + if (sourceIndex === undefined) return; + bumpVisibleLargeValuePreviewCellRevision(options.result.value, sourceIndex, columnIndex); + const cache = visibleLargeValuePreviewCaches.get(options.result.value); + if (!cache?.has(sourceIndex, columnIndex)) return; + cache.forget(sourceIndex, columnIndex); + visibleLargeValuePreviewVersion.value += 1; + options.scheduleCanvasDraw(); + } + + function visibleLargeValuePreviewValue(item: Pick | undefined, columnIndex: number, fallback: CellValue): CellValue { + if (item?.sourceIndex === undefined) return fallback; + const preview = visibleLargeValuePreviewCaches.get(options.result.value)?.get(item.sourceIndex, columnIndex); + return preview === undefined ? fallback : preview; + } + + function visibleLargeValuePreviewColumnIndexes(): number[] { + const rendered = options.renderedGridColumns.value.map((column) => column.actualColIdx); + return [...new Set(rendered)].filter((columnIndex) => { + const dataType = options.tableColumnForGridColumn(columnIndex)?.data_type ?? options.allColumnTypes.value[columnIndex] ?? ""; + return isTableDataVisiblePreviewColumn(options.databaseType.value, dataType); + }); + } + + function evictVisibleLargeValuePreviews(cache: ResultScopedRowCache, protectedRows: ReadonlySet): boolean { + return cache.evict(protectedRows).length > 0; + } + + async function hydrateVisibleLargeValuePreviews(generation: number) { + const sourceResult = options.result.value; + const tableMeta = options.tableMeta.value; + const scroller = options.gridScrollerElement(); + if ( + !visibleLargeValuePreviewActive || + generation !== visibleLargeValuePreviewRequestedGeneration || + failedVisibleLargeValuePreviewResults.has(sourceResult) || + options.showTranspose.value || + (options.databaseType.value !== "mysql" && options.databaseType.value !== "postgres") || + !options.connectionId.value || + !tableMeta?.tableName || + tableMeta.primaryKeys.length === 0 || + !scroller + ) + return; + + const range = tableDataVisiblePreviewRowRange(scroller.scrollTop, scroller.clientHeight, CANVAS_DATA_GRID_ROW_HEIGHT, options.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 = options.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; + options.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 = options.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 || options.result.value !== sourceResult) return; + const sql = await buildTableSelectSql({ + databaseType: options.databaseType.value, + identifierQuote: options.connectionIdentifierQuote(options.connectionId.value), + 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, + includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + }); + if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || options.result.value !== sourceResult) return; + const connection = options.getConnectionConfig(options.connectionId.value!); + const executionId = options.uuid(); + visibleLargeValuePreviewExecutionIds.set(generation, executionId); + let results: QueryResult[]; + try { + results = await api.executeMulti(options.connectionId.value, options.executionDatabase.value, sql, undefined, executionId, { + maxRows: requests.size, + fetchSize: requests.size, + resultKeyColumns: tableMeta.primaryKeys, + tableDataPreview: true, + timeoutSecs: queryTimeoutSecsForConnection(connection, options.globalQueryTimeoutSecs.value), + }); + } finally { + if (visibleLargeValuePreviewExecutionIds.get(generation) === executionId) visibleLargeValuePreviewExecutionIds.delete(generation); + } + if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || options.result.value !== sourceResult) return; + const result = results[0]; + if (!result || result.execution_error) { + throw new Error(result?.error ? options.translateBackendError(result.error) : String(result?.rows?.[0]?.[0] ?? options.translate("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(options.translate("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 = options.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; + options.scheduleCanvasDraw(); + } + + async function runVisibleLargeValuePreviewHydration(generation: number) { + if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration) return; + const sourceResult = options.result.value; + try { + await hydrateVisibleLargeValuePreviews(generation); + } catch (error) { + if (options.result.value === sourceResult && generation === visibleLargeValuePreviewRequestedGeneration && !failedVisibleLargeValuePreviewResults.has(sourceResult)) { + failedVisibleLargeValuePreviewResults.add(sourceResult); + options.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) => options.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( + () => [options.result.value, options.result.value.rows.length, options.renderedGridColumns.value.map((column) => column.actualColIdx).join(","), options.showTranspose.value] as const, + () => { + nextTick(() => scheduleVisibleLargeValuePreviewHydration()); + }, + { immediate: true }, + ); + onActivated(resumeVisibleLargeValuePreviewHydration); + onDeactivated(pauseVisibleLargeValuePreviewHydration); + options.runtimeScope.addCleanup(pauseVisibleLargeValuePreviewHydration); + + function chunkLargeValueRequests(requests: LargeValueCellRequest[]): LargeValueCellRequest[][] { + const chunks: LargeValueCellRequest[][] = []; + let chunk: LargeValueCellRequest[] = []; + let bytes = 0; + for (const request of requests) { + if (chunk.length > 0 && (chunk.length >= LARGE_VALUE_FETCH_MAX_ROWS || bytes + request.originalBytes > LARGE_VALUE_FETCH_TARGET_BYTES)) { + chunks.push(chunk); + chunk = []; + bytes = 0; + } + chunk.push(request); + bytes += request.originalBytes; + } + if (chunk.length > 0) chunks.push(chunk); + return chunks; + } + + async function largeValueRowPredicate(item: LargeValueRowItem, primaryKeyIndexes: number[]): Promise { + const originalRow = item.sourceIndex === undefined ? undefined : options.result.value.rows[item.sourceIndex]; + if (!originalRow) throw new Error(options.translate("grid.largeValueRowUnavailable")); + const tableMeta = options.tableMeta.value!; + const conditions = await Promise.all( + tableMeta.primaryKeys.map((columnName, index) => + buildDataGridContextFilterCondition({ + databaseType: options.databaseType.value, + identifierQuote: options.connectionIdentifierQuote(options.connectionId.value), + columnName, + columnInfo: tableMeta.columns.find((column) => column.name.toLocaleLowerCase() === columnName.toLocaleLowerCase()), + mode: "equals", + value: originalRow[primaryKeyIndexes[index]!] ?? null, + }), + ), + ); + if (conditions.some((condition) => !condition)) throw new Error(options.translate("grid.largeValueRowUnavailable")); + return conditions.map((condition) => `(${condition})`).join(" AND "); + } + + async function fetchLargeValueRequestChunk(columnIndex: number, requests: LargeValueCellRequest[], primaryKeyIndexes: number[], resolved: ResolvedLargeValueCells) { + const tableMeta = options.tableMeta.value!; + const sourceColumn = options.resultSourceColumns.value[columnIndex]; + if (!sourceColumn) throw new Error(options.translate("grid.largeValueColumnUnavailable")); + const predicates = await Promise.all(requests.map((request) => largeValueRowPredicate(request.item, primaryKeyIndexes))); + const selectedColumns = [...new Set([...tableMeta.primaryKeys, sourceColumn])]; + const sql = await buildTableSelectSql({ + databaseType: options.databaseType.value, + identifierQuote: options.connectionIdentifierQuote(options.connectionId.value), + database: tableMeta.database, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + tableType: tableMeta.tableType, + catalog: tableMeta.catalog, + columns: selectedColumns, + includeDatabaseName: options.includeDatabaseName.value, + primaryKeys: tableMeta.primaryKeys, + whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "), + limit: requests.length, + offset: 0, + includeRowId: shouldIncludeSyntheticRowId(options.databaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + }); + const connection = options.connectionId.value ? options.getConnectionConfig(options.connectionId.value!) : undefined; + const results = await api.executeMulti(options.connectionId.value!, options.executionDatabase.value, sql, undefined, options.uuid(), { + maxRows: requests.length, + fetchSize: requests.length, + timeoutSecs: queryTimeoutSecsForConnection(connection, options.globalQueryTimeoutSecs.value), + }); + const result = results[0]; + if (!result || result.execution_error) { + throw new Error(result?.error ? options.translateBackendError(result.error) : String(result?.rows?.[0]?.[0] ?? options.translate("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(options.translate("grid.largeValueColumnUnavailable")); + const resultPrimaryKeyIndexes = resultColumnIndexes.slice(0, tableMeta.primaryKeys.length); + const resultValueIndex = resultColumnIndexes[selectedColumns.indexOf(sourceColumn)]; + const valuesByIdentity = new Map(result.rows.map((row) => [largeValueIdentityKey(row, resultPrimaryKeyIndexes), row[resultValueIndex!] ?? null])); + for (const request of requests) { + const originalRow = options.result.value.rows[request.sourceIndex]; + const identity = originalRow ? largeValueIdentityKey(originalRow, primaryKeyIndexes) : ""; + if (!valuesByIdentity.has(identity)) throw new Error(options.translate("grid.largeValueRowUnavailable")); + const rowValues = resolved.get(request.item.id) ?? new Map(); + rowValues.set(columnIndex, valuesByIdentity.get(identity) ?? null); + resolved.set(request.item.id, rowValues); + } + } + + async function resolveLargeValueCells(rowIds: number[], columnIndexes: number[]): Promise { + const resolved: ResolvedLargeValueCells = new Map(); + const requestedColumns = new Set(columnIndexes); + const requestsByColumn = new Map(); + for (const rowId of new Set(rowIds)) { + const item = options.getRowItem(rowId); + if (!item || item.sourceIndex === undefined) continue; + for (const columnIndex of requestedColumns) { + if (!isLargeValuePreview(item, columnIndex)) continue; + const metadata = largeValueCellsByKey.value.get(largeValueCellKey(item.sourceIndex, columnIndex)); + if (!metadata) continue; + const requests = requestsByColumn.get(columnIndex) ?? []; + requests.push({ item, sourceIndex: item.sourceIndex, columnIndex, originalBytes: metadata.original_bytes }); + requestsByColumn.set(columnIndex, requests); + } + } + if (requestsByColumn.size === 0) return resolved; + if ((options.databaseType.value !== "mysql" && options.databaseType.value !== "postgres" && options.databaseType.value !== "oracle") || !options.connectionId.value || !options.tableMeta.value?.tableName || options.tableMeta.value.primaryKeys.length === 0) { + throw new Error(options.translate("grid.largeValueNeedsStableKey")); + } + const primaryKeyIndexes = options.tableMeta.value.primaryKeys.map(largeValueSourceColumnIndex); + if (primaryKeyIndexes.some((index) => index < 0)) throw new Error(options.translate("grid.largeValueNeedsStableKey")); + + for (const [columnIndex, requests] of requestsByColumn) { + for (const chunk of chunkLargeValueRequests(requests)) { + await fetchLargeValueRequestChunk(columnIndex, chunk, primaryKeyIndexes, resolved); + } + } + return resolved; + } + + function reportLargeValueLoadError(error: unknown) { + options.toast(options.translate("grid.largeValueLoadFailedWithMessage", { message: options.translateBackendError(error) }), 5000); + } + + async function cloneRow(rowId: number) { + try { + const resolved = await resolveLargeValueCells( + [rowId], + options.result.value.columns.map((_, index) => index), + ); + options.cloneRow(rowId, resolved.get(rowId)); + } catch (error) { + reportLargeValueLoadError(error); + } + } + + async function cloneRows(rowIds: number[]) { + try { + const resolved = await resolveLargeValueCells( + rowIds, + options.result.value.columns.map((_, index) => index), + ); + options.cloneRows(rowIds, resolved); + } catch (error) { + reportLargeValueLoadError(error); + } + } + + async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promise { + const item = options.getRowItem(rowId); + if (!isLargeValuePreview(item, columnIndex) || item?.sourceIndex === undefined) return true; + const sourceResult = options.result.value; + const hydrationKey = largeValueCellKey(item.sourceIndex, columnIndex); + const operation = options.resultLifecycle.beginOperation(); + return pendingLargeValueHydrations.run(hydrationKey, sourceResult, async () => { + try { + const resolved = await resolveLargeValueCells([rowId], [columnIndex]); + if (!options.resultLifecycle.isCurrent(operation) || options.result.value !== sourceResult) return false; + const value = resolved.get(rowId)?.get(columnIndex); + if (value === undefined && !resolved.get(rowId)?.has(columnIndex)) return false; + const row = [...(sourceResult.rows[item.sourceIndex!] ?? [])]; + row[columnIndex] = value ?? null; + 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); + options.largeValueResolutionVersion.value += 1; + options.clearCellFormatCache(); + options.invalidateResultEstimate(sourceResult); + return true; + } catch (error) { + if (options.resultLifecycle.isCurrent(operation) && options.result.value === sourceResult) reportLargeValueLoadError(error); + return false; + } + }); + } + + return { + isLargeValuePreview, + largeValueOriginalBytes, + formatGridItemCell, + formatGridItemCellForConfirmation, + visibleLargeValuePreviewValue, + resolveLargeValueCells, + hydrateLargeValueCell, + invalidateVisibleLargeValuePreviewCell, + scheduleVisibleLargeValuePreviewHydration, + reportLargeValueLoadError, + cloneRow, + cloneRows, + }; +}