refactor(grid): extract large value workflow
This commit is contained in:
5 files changed
+634
-500
No files matched your search
@@ -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<typeof useDataGridLargeValues>;
|
||||
|
||||
function scheduleVisibleLargeValuePreviewHydration(delay = 150) {
|
||||
largeValueRuntime.scheduleVisibleLargeValuePreviewHydration(delay);
|
||||
}
|
||||
|
||||
async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promise<boolean> {
|
||||
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<RowItem[]>(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
type ResolvedLargeValueCells = Map<number, Map<number, CellValue>>;
|
||||
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<boolean>();
|
||||
const largeValueCellsByKey = computed(() => largeValueCellMap(props.result));
|
||||
type VisibleLargeValuePreviewRequest = {
|
||||
item: RowItem;
|
||||
sourceIndex: number;
|
||||
initialValues: Map<number, { value: CellValue; revision: number }>;
|
||||
};
|
||||
const visibleLargeValuePreviewCaches = new WeakMap<QueryResult, ResultScopedRowCache<CellValue>>();
|
||||
const visibleLargeValuePreviewCellRevisions = new WeakMap<QueryResult, Map<string, number>>();
|
||||
const failedVisibleLargeValuePreviewResults = new WeakSet<QueryResult>();
|
||||
const visibleLargeValuePreviewVersion = ref(0);
|
||||
let visibleLargeValuePreviewTimer = 0;
|
||||
let visibleLargeValuePreviewRequestedGeneration = 0;
|
||||
let visibleLargeValuePreviewActive = true;
|
||||
const visibleLargeValuePreviewExecutionIds = new Map<number, string>();
|
||||
|
||||
function isLargeValuePreview(item: RowItem | undefined, columnIndex: number): boolean {
|
||||
if (!item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return false;
|
||||
return largeValueCellsByKey.value.has(largeValueCellKey(item.sourceIndex, columnIndex));
|
||||
}
|
||||
|
||||
function largeValueOriginalBytes(item: Pick<RowItem, "sourceIndex" | "isNew" | "isDraft" | "isDirtyCol"> | 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<CellValue> {
|
||||
let cache = visibleLargeValuePreviewCaches.get(result);
|
||||
if (!cache) {
|
||||
cache = createResultScopedRowCache(TABLE_DATA_VISIBLE_PREVIEW_CACHE_ROWS, {
|
||||
maxBytes: TABLE_DATA_VISIBLE_PREVIEW_CACHE_CONTENT_MAX_BYTES,
|
||||
sizeOf: tableDataVisiblePreviewContentBytes,
|
||||
});
|
||||
visibleLargeValuePreviewCaches.set(result, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewCellRevisionKey(sourceIndex: number, columnIndex: number): string {
|
||||
return `${sourceIndex}:${columnIndex}`;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number): number {
|
||||
return visibleLargeValuePreviewCellRevisions.get(result)?.get(visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex)) ?? 0;
|
||||
}
|
||||
|
||||
function bumpVisibleLargeValuePreviewCellRevision(result: QueryResult, sourceIndex: number, columnIndex: number) {
|
||||
let revisions = visibleLargeValuePreviewCellRevisions.get(result);
|
||||
if (!revisions) {
|
||||
revisions = new Map<string, number>();
|
||||
visibleLargeValuePreviewCellRevisions.set(result, revisions);
|
||||
}
|
||||
const key = visibleLargeValuePreviewCellRevisionKey(sourceIndex, columnIndex);
|
||||
revisions.set(key, (revisions.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function invalidateVisibleLargeValuePreviewCell(rowId: number, columnIndex: number) {
|
||||
const item = getRowItem(rowId);
|
||||
const sourceIndex = item?.sourceIndex ?? (rowId >= 0 ? rowId : undefined);
|
||||
if (sourceIndex === undefined) return;
|
||||
bumpVisibleLargeValuePreviewCellRevision(props.result, sourceIndex, columnIndex);
|
||||
const cache = visibleLargeValuePreviewCaches.get(props.result);
|
||||
if (!cache?.has(sourceIndex, columnIndex)) return;
|
||||
cache.forget(sourceIndex, columnIndex);
|
||||
visibleLargeValuePreviewVersion.value += 1;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewValue(item: Pick<RowItem, "sourceIndex"> | undefined, columnIndex: number, fallback: CellValue): CellValue {
|
||||
if (item?.sourceIndex === undefined) return fallback;
|
||||
const preview = visibleLargeValuePreviewCaches.get(props.result)?.get(item.sourceIndex, columnIndex);
|
||||
return preview === undefined ? fallback : preview;
|
||||
}
|
||||
|
||||
function visibleLargeValuePreviewColumnIndexes(): number[] {
|
||||
const rendered = renderedGridColumns.value.map((column) => column.actualColIdx);
|
||||
return [...new Set(rendered)].filter((columnIndex) => {
|
||||
const dataType = tableColumnForGridColumn(columnIndex)?.data_type ?? allColumnTypes.value[columnIndex] ?? "";
|
||||
return isTableDataVisiblePreviewColumn(resolvedDatabaseType.value, dataType);
|
||||
});
|
||||
}
|
||||
|
||||
function evictVisibleLargeValuePreviews(cache: ResultScopedRowCache<CellValue>, protectedRows: ReadonlySet<number>): boolean {
|
||||
return cache.evict(protectedRows).length > 0;
|
||||
}
|
||||
|
||||
async function hydrateVisibleLargeValuePreviews(generation: number) {
|
||||
const sourceResult = props.result;
|
||||
const tableMeta = props.tableMeta;
|
||||
const scroller = gridScrollerElement();
|
||||
if (
|
||||
!visibleLargeValuePreviewActive ||
|
||||
generation !== visibleLargeValuePreviewRequestedGeneration ||
|
||||
failedVisibleLargeValuePreviewResults.has(sourceResult) ||
|
||||
showTranspose.value ||
|
||||
(resolvedDatabaseType.value !== "mysql" && resolvedDatabaseType.value !== "postgres") ||
|
||||
!props.connectionId ||
|
||||
!tableMeta?.tableName ||
|
||||
tableMeta.primaryKeys.length === 0 ||
|
||||
!scroller
|
||||
)
|
||||
return;
|
||||
|
||||
const range = tableDataVisiblePreviewRowRange(scroller.scrollTop, scroller.clientHeight, CANVAS_DATA_GRID_ROW_HEIGHT, displayRowCount.value);
|
||||
if (!range) return;
|
||||
const cache = visibleLargeValuePreviewCache(sourceResult);
|
||||
const activeSourceRows = new Set<number>();
|
||||
const requests = new Map<number, VisibleLargeValuePreviewRequest>();
|
||||
const targetColumnIndexes = visibleLargeValuePreviewColumnIndexes();
|
||||
for (let displayIndex = range.start; displayIndex < range.end; displayIndex++) {
|
||||
const item = displayItemAt(displayIndex);
|
||||
if (!item || item.sourceIndex === undefined || item.isNew || item.isDraft) continue;
|
||||
activeSourceRows.add(item.sourceIndex);
|
||||
cache.touch(item.sourceIndex);
|
||||
const initialValues = new Map<number, { value: CellValue; revision: number }>();
|
||||
for (const columnIndex of targetColumnIndexes) {
|
||||
if (!isLargeValuePreview(item, columnIndex) || cache.has(item.sourceIndex, columnIndex)) continue;
|
||||
initialValues.set(columnIndex, {
|
||||
value: item.data[columnIndex] ?? null,
|
||||
revision: visibleLargeValuePreviewCellRevision(sourceResult, item.sourceIndex, columnIndex),
|
||||
});
|
||||
}
|
||||
if (initialValues.size > 0) requests.set(item.sourceIndex, { item, sourceIndex: item.sourceIndex, initialValues });
|
||||
}
|
||||
|
||||
if (requests.size === 0) {
|
||||
if (evictVisibleLargeValuePreviews(cache, activeSourceRows)) {
|
||||
visibleLargeValuePreviewVersion.value += 1;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryKeyIndexes = tableMeta.primaryKeys.map(largeValueSourceColumnIndex);
|
||||
if (primaryKeyIndexes.some((index) => index < 0)) return;
|
||||
const requestedColumnIndexes = [...new Set([...requests.values()].flatMap((request) => [...request.initialValues.keys()]))];
|
||||
const selectedColumns = [...tableMeta.primaryKeys];
|
||||
for (const columnIndex of requestedColumnIndexes) {
|
||||
const sourceColumn = resultSourceColumns.value[columnIndex];
|
||||
if (sourceColumn && !selectedColumns.some((column) => column.toLocaleLowerCase() === sourceColumn.toLocaleLowerCase())) selectedColumns.push(sourceColumn);
|
||||
}
|
||||
const selectedColumnTypes = selectedColumns.map((column) => tableMeta.columns.find((candidate) => candidate.name.toLocaleLowerCase() === column.toLocaleLowerCase())?.data_type ?? "");
|
||||
const predicates = await Promise.all([...requests.values()].map((request) => largeValueRowPredicate(request.item, primaryKeyIndexes)));
|
||||
if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
database: tableMeta.database,
|
||||
schema: tableMeta.schema,
|
||||
tableName: tableMeta.tableName,
|
||||
tableType: tableMeta.tableType,
|
||||
catalog: tableMeta.catalog,
|
||||
columns: selectedColumns,
|
||||
columnTypes: selectedColumnTypes,
|
||||
largeValuePreviewSize: TABLE_DATA_VISIBLE_PREVIEW_SIZE,
|
||||
primaryKeys: tableMeta.primaryKeys,
|
||||
whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "),
|
||||
limit: requests.size,
|
||||
offset: 0,
|
||||
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<string> {
|
||||
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<number, CellValue>();
|
||||
rowValues.set(columnIndex, valuesByIdentity.get(identity) ?? null);
|
||||
resolved.set(request.item.id, rowValues);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLargeValueCells(rowIds: number[], columnIndexes: number[]): Promise<ResolvedLargeValueCells> {
|
||||
const resolved: ResolvedLargeValueCells = new Map();
|
||||
const requestedColumns = new Set(columnIndexes);
|
||||
const requestsByColumn = new Map<number, LargeValueCellRequest[]>();
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
+3
-1
@@ -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\)/);
|
||||
});
|
||||
|
||||
+2
-1
@@ -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", () => {
|
||||
|
||||
+6
-5
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user