perf(mysql): limit large table result memory
This commit is contained in:
32 files changed
+1441
-177
No files matched your search
@@ -149,6 +149,7 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowSho
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||
import { largeValueCellKey, largeValueCellMap } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
@@ -195,6 +196,7 @@ import {
|
||||
import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssociationCells, foreignKeyMetadataRequestCurrent, foreignKeyNavigationTarget, foreignKeySourceColumnName, foreignKeyTableIdentity, type ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
|
||||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
@@ -256,6 +258,7 @@ import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from
|
||||
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 timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
@@ -283,6 +286,7 @@ const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||||
const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH);
|
||||
const largeValueResolutionVersion = ref(0);
|
||||
const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose);
|
||||
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
|
||||
const booleanCellsUseCheckbox = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode === "checkbox");
|
||||
@@ -3146,7 +3150,7 @@ const {
|
||||
onEditKeydown,
|
||||
addRows: addEditorRows,
|
||||
appendPastedRowsToNewRow,
|
||||
cloneRow,
|
||||
cloneRow: cloneEditorRow,
|
||||
showDeleteRowConfirm,
|
||||
requestDeleteRow,
|
||||
confirmDeleteRow,
|
||||
@@ -3154,7 +3158,7 @@ const {
|
||||
restoreRows,
|
||||
pendingDeleteRowIds,
|
||||
requestDeleteRows,
|
||||
cloneRows,
|
||||
cloneRows: cloneEditorRows,
|
||||
saveChanges,
|
||||
discardChanges,
|
||||
canUndoPendingChange,
|
||||
@@ -3301,15 +3305,17 @@ function cellUsesExpandedEditor(rowId: number | undefined, columnIndex: number):
|
||||
return !!expandedCellEditor.value && expandedCellEditor.value.rowId === rowId && expandedCellEditor.value.col === columnIndex;
|
||||
}
|
||||
|
||||
function startCellEdit(rowId: number, columnIndex: number, expanded: boolean) {
|
||||
async function startCellEdit(rowId: number, columnIndex: number, expanded: boolean) {
|
||||
if (!(await hydrateLargeValueCell(rowId, columnIndex))) return;
|
||||
expandedCellEditor.value = expanded ? { rowId, col: columnIndex } : null;
|
||||
startEdit(rowId, columnIndex);
|
||||
}
|
||||
|
||||
function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) {
|
||||
async function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) {
|
||||
if (!(await hydrateLargeValueCell(rowId, columnIndex))) return;
|
||||
const item = getRowItem(rowId);
|
||||
const editText = item ? cellEditorTextForValue(item.data[columnIndex], columnIndex) : displayText;
|
||||
startCellEdit(
|
||||
await startCellEdit(
|
||||
rowId,
|
||||
columnIndex,
|
||||
cellEditContentNeedsExpandedEditor({
|
||||
@@ -3326,10 +3332,10 @@ function showReadonlyCellDetailsOnDblClick(item: RowItem, rowIndex: number, visi
|
||||
return true;
|
||||
}
|
||||
|
||||
function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) {
|
||||
async function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) {
|
||||
if (booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return;
|
||||
if (showReadonlyCellDetailsOnDblClick(item, rowIndex, visibleColIdx, actualColIdx)) return;
|
||||
startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event);
|
||||
await startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event);
|
||||
}
|
||||
|
||||
function cellEditContentNeedsExpandedEditor(options: { displayText: string; editText: string; target: EventTarget | null }): boolean {
|
||||
@@ -3775,6 +3781,7 @@ const displayRowIndexByIdLookup = computed(() => {
|
||||
});
|
||||
|
||||
function rowItemFromDisplayRef(ref: DisplayRowRef): RowItem {
|
||||
largeValueResolutionVersion.value;
|
||||
if (ref.isNew) {
|
||||
return {
|
||||
...ref,
|
||||
@@ -3954,6 +3961,215 @@ const visibleDisplayItems = computed<RowItem[]>(() =>
|
||||
isDirtyCol: visibleDirtyColumns(item.isDirtyCol),
|
||||
})),
|
||||
);
|
||||
|
||||
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 = new Map<string, Promise<boolean>>();
|
||||
const largeValueCellsByKey = computed(() => largeValueCellMap(props.result));
|
||||
|
||||
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 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 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,
|
||||
primaryKeys: tableMeta.primaryKeys,
|
||||
whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "),
|
||||
limit: requests.length,
|
||||
offset: 0,
|
||||
});
|
||||
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" || !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 hydrationKey = largeValueCellKey(item.sourceIndex, columnIndex);
|
||||
const pending = pendingLargeValueHydrations.get(hydrationKey);
|
||||
if (pending) return pending;
|
||||
const hydration = (async () => {
|
||||
try {
|
||||
const resolved = await resolveLargeValueCells([rowId], [columnIndex]);
|
||||
const value = resolved.get(rowId)?.get(columnIndex);
|
||||
if (value === undefined && !resolved.get(rowId)?.has(columnIndex)) return false;
|
||||
const row = [...(props.result.rows[item.sourceIndex!] ?? [])];
|
||||
row[columnIndex] = value ?? null;
|
||||
const rows = props.result.rows.slice();
|
||||
rows[item.sourceIndex!] = row;
|
||||
props.result.rows = rows;
|
||||
props.result.large_value_cells = props.result.large_value_cells?.filter((cell) => cell.row_index !== item.sourceIndex || cell.column_index !== columnIndex);
|
||||
largeValueResolutionVersion.value += 1;
|
||||
clearCellFormatCache();
|
||||
queryStore.invalidateResultEstimateForPayload(props.result);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
return false;
|
||||
} finally {
|
||||
pendingLargeValueHydrations.delete(hydrationKey);
|
||||
}
|
||||
})();
|
||||
pendingLargeValueHydrations.set(hydrationKey, hydration);
|
||||
return hydration;
|
||||
}
|
||||
|
||||
const exportContextCell = computed(() => {
|
||||
if (!contextCell.value) return null;
|
||||
const visibleCol = visibleColumnIndexes.value.indexOf(contextCell.value.col);
|
||||
@@ -4330,6 +4546,7 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai
|
||||
canEditCell: canEditCellItem(item, columnIndex),
|
||||
isDraft: !!item.isDraft,
|
||||
}),
|
||||
isValuePreviewTruncated: isLargeValuePreview(item, columnIndex),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4402,6 +4619,7 @@ const rowDetail = computed(() => {
|
||||
canEditCell: canEditCellItem(item, columnIndex),
|
||||
isDraft: !!item.isDraft,
|
||||
}),
|
||||
isValuePreviewTruncated: (columnIndex) => isLargeValuePreview(item, columnIndex),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4419,6 +4637,7 @@ const columnDetail = computed(() => {
|
||||
canEditCell: canEditCellItem(item, columnIndex),
|
||||
isDraft: !!item.isDraft,
|
||||
}),
|
||||
isValuePreviewTruncated: isLargeValuePreview(item, columnIndex),
|
||||
})),
|
||||
columns: props.result.columns,
|
||||
columnIndex,
|
||||
@@ -4477,7 +4696,7 @@ watch(activeCellDetailTabs, (tabs) => {
|
||||
|
||||
watch(activeCellDetailTab, (tab) => {
|
||||
if (tab === "valueEditor") {
|
||||
startDetailEdit();
|
||||
void startDetailEdit();
|
||||
} else {
|
||||
resetDetailEdit();
|
||||
}
|
||||
@@ -4541,6 +4760,8 @@ const detailSqlConditionKey = computed(() => {
|
||||
});
|
||||
|
||||
function canCopyPreparedDetailSqlCondition(): boolean {
|
||||
const detail = activeCellDetail.value;
|
||||
if (detail && isLargeValuePreview(getRowItem(detail.rowId), detail.colIndex)) return false;
|
||||
return detailSqlConditionCopy.value.ready && detailSqlConditionCopy.value.key === detailSqlConditionKey.value;
|
||||
}
|
||||
|
||||
@@ -4726,7 +4947,10 @@ function toggleCellDetailMetadataCollapsed() {
|
||||
});
|
||||
}
|
||||
|
||||
function startDetailEdit() {
|
||||
async function startDetailEdit() {
|
||||
const initialDetail = activeCellDetail.value;
|
||||
if (!initialDetail || !initialDetail.isEditable) return;
|
||||
if (!(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return;
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail || !detail.isEditable) return;
|
||||
warnFormattedJsonEditIfNeeded(detail);
|
||||
@@ -4843,6 +5067,10 @@ function setDetailNull() {
|
||||
|
||||
function applyColumnSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, mode: DataGridSortMode = "database") {
|
||||
if (getIsResizing()) return;
|
||||
if (mode === "local" && direction && props.result.large_value_cells?.some((cell) => cell.column_index === columnIndex)) {
|
||||
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
|
||||
return;
|
||||
}
|
||||
if (mode === "database" && infiniteScrollEnabled.value) {
|
||||
resetInfiniteScrollState();
|
||||
} else {
|
||||
@@ -4879,6 +5107,9 @@ function applyContextSort(direction: "asc" | "desc" | null, mode: DataGridSortMo
|
||||
|
||||
async function contextFilterCondition(mode: FilterMode): Promise<string | null> {
|
||||
if (!contextColumn.value) return null;
|
||||
if (mode !== "is-null" && mode !== "is-not-null" && contextCell.value) {
|
||||
if (!(await hydrateLargeValueCell(contextCell.value.rowId, contextCell.value.col))) return null;
|
||||
}
|
||||
return (
|
||||
(await buildDataGridContextFilterCondition({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
@@ -4998,6 +5229,7 @@ async function applyWhereFilter() {
|
||||
|
||||
const CELL_FORMAT_CACHE_LIMIT = 20_000;
|
||||
const CELL_FORMAT_CACHE_PRUNE_COUNT = 5_000;
|
||||
const CELL_FORMAT_CACHE_STRING_KEY_MAX_LENGTH = 512;
|
||||
|
||||
const resolvedColumnFormatters = computed(() => props.result.columns.map((_, columnIndex) => columnFormatter(columnIndex)));
|
||||
const columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature));
|
||||
@@ -5069,6 +5301,13 @@ function formatCellCached(value: CellValue, columnIndex?: number): string {
|
||||
return display;
|
||||
}
|
||||
|
||||
// Avoid retaining a second copy of large text values in Map keys. Long
|
||||
// values are truncated for display anyway, so formatting them on demand is
|
||||
// cheaper than keeping the full source string in the primitive cache.
|
||||
if (typeof value === "string" && value.length > CELL_FORMAT_CACHE_STRING_KEY_MAX_LENGTH) {
|
||||
return formatCell(value, columnIndex);
|
||||
}
|
||||
|
||||
const key = primitiveCellFormatKey(value, columnIndex);
|
||||
const cached = primitiveCellFormatCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
@@ -5635,7 +5874,7 @@ function onCanvasContext(event: MouseEvent) {
|
||||
onCellContext(item.id, item.displayIndex, actualColIdx, hit.visibleColIdx, event);
|
||||
}
|
||||
|
||||
function onCanvasDblClick(event: MouseEvent) {
|
||||
async function onCanvasDblClick(event: MouseEvent) {
|
||||
const hit = canvasHitTest(event);
|
||||
if (!hit) return;
|
||||
if (hit.rowNumber) {
|
||||
@@ -5648,7 +5887,7 @@ function onCanvasDblClick(event: MouseEvent) {
|
||||
if (!item || actualColIdx === undefined) return;
|
||||
if (showReadonlyCellDetailsOnDblClick(item, item.displayIndex, hit.visibleColIdx, actualColIdx)) return;
|
||||
if (booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return;
|
||||
startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx));
|
||||
await startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx));
|
||||
}
|
||||
|
||||
function canvasCellContentOverflows(item: RowItem, actualColIdx: number, visibleColIdx: number): boolean {
|
||||
@@ -5961,6 +6200,7 @@ onMounted(() => {
|
||||
onDeactivated(pauseCanvasGridWork);
|
||||
onUnmounted(() => {
|
||||
dataGridRuntimeScope.dispose();
|
||||
clearCellFormatCache();
|
||||
pauseCanvasGridWork();
|
||||
canvasRuntime.dispose();
|
||||
gridScrollbarsRuntime.dispose();
|
||||
@@ -6070,6 +6310,7 @@ const {
|
||||
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
resolveSourceValues: resolveLargeValueCells,
|
||||
fullExportResult: props.fullExportResult,
|
||||
queryResultExportRequest: props.queryResultExportRequest,
|
||||
hasCompleteLocalResult,
|
||||
@@ -6300,6 +6541,8 @@ function showCellDetails(rowIndex: number, colIndex: number) {
|
||||
detailCell.value = { rowIndex, col: colIndex };
|
||||
activeCellDetailTab.value = defaultCellDetailTab();
|
||||
showCellDetail.value = true;
|
||||
const item = displayItemAt(rowIndex);
|
||||
if (item && isLargeValuePreview(item, colIndex)) void hydrateLargeValueCell(item.id, colIndex);
|
||||
}
|
||||
|
||||
function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
|
||||
@@ -6312,6 +6555,8 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number,
|
||||
function openCellDetailDialog(rowIndex: number, columnIndex: number) {
|
||||
cellDetailDialogTarget.value = { rowIndex, col: columnIndex };
|
||||
cellDetailDialogOpen.value = true;
|
||||
const item = displayItemAt(rowIndex);
|
||||
if (item && isLargeValuePreview(item, columnIndex)) void hydrateLargeValueCell(item.id, columnIndex);
|
||||
}
|
||||
|
||||
function openColumnDetailDialog(columnIndex: number) {
|
||||
@@ -6416,14 +6661,14 @@ function showTransposeCellDetails(rowIndex: number, actualColIdx: number) {
|
||||
gridRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, displayText: string, event: MouseEvent) {
|
||||
async function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, displayText: string, event: MouseEvent) {
|
||||
const item = displayItemAt(rowIndex);
|
||||
if (!item) return;
|
||||
if (!canEditCellItem(item, actualColIdx)) {
|
||||
showTransposeCellDetails(rowIndex, actualColIdx);
|
||||
return;
|
||||
}
|
||||
startDomCellEdit(item.id, actualColIdx, displayText, event);
|
||||
await startDomCellEdit(item.id, actualColIdx, displayText, event);
|
||||
}
|
||||
|
||||
function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: MouseEvent) {
|
||||
@@ -6783,10 +7028,10 @@ function generateSelectionMenuItems(disabled: boolean): ContextMenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function cutSelection() {
|
||||
async function cutSelection() {
|
||||
if (!props.editable || !selectedRange.value) return;
|
||||
void copyWithExtractor("tsv");
|
||||
const range = selectedRange.value;
|
||||
if (!(await copyWithExtractor("tsv"))) return;
|
||||
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
|
||||
beginBatch();
|
||||
try {
|
||||
@@ -7038,7 +7283,7 @@ function editSelectedCell(): boolean {
|
||||
const item = displayItemAt(position.rowIndex);
|
||||
const actualColIndex = actualColumnIndex(position.colIndex);
|
||||
if (!item || !canEditCellItem(item, actualColIndex)) return false;
|
||||
startEdit(item.id, actualColIndex);
|
||||
void startCellEdit(item.id, actualColIndex, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7056,10 +7301,10 @@ function copyCurrentRow(): boolean {
|
||||
const rowIds = selectedOrCurrentRowIds().filter((rowId) => !getRowItem(rowId)?.isDraft);
|
||||
if (rowIds.length === 0) return false;
|
||||
if (rowIds.length === 1) {
|
||||
cloneRow(rowIds[0]);
|
||||
void cloneRow(rowIds[0]);
|
||||
return true;
|
||||
}
|
||||
cloneRows(rowIds);
|
||||
void cloneRows(rowIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7101,7 +7346,7 @@ async function commitEditFromCellBlur() {
|
||||
await commitEditFromBlur({ promoteDraft: false });
|
||||
nextTick(() => {
|
||||
const item = getRowItem(target.rowId);
|
||||
if (item && canEditCellItem(item, target.col)) startEdit(target.rowId, target.col);
|
||||
if (item && canEditCellItem(item, target.col)) void startCellEdit(target.rowId, target.col, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -7303,7 +7548,7 @@ async function onGridKeydown(event: KeyboardEvent) {
|
||||
if (clipboardShortcut(event, "x")) {
|
||||
if (!props.editable || !selectedRange.value) return;
|
||||
event.preventDefault();
|
||||
cutSelection();
|
||||
await cutSelection();
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "v")) {
|
||||
@@ -7316,24 +7561,28 @@ async function onGridKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyDetailValue() {
|
||||
async function copyDetailValue() {
|
||||
const initialDetail = activeCellDetail.value;
|
||||
if (!initialDetail || !(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return;
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail) return;
|
||||
const text = detail.value === null ? "" : displayCellValue(detail.value);
|
||||
copyText(text);
|
||||
}
|
||||
|
||||
function copyDetailFormattedJson() {
|
||||
async function copyDetailFormattedJson() {
|
||||
const initialDetail = activeCellDetail.value;
|
||||
if (!initialDetail || !(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return;
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail?.formattedJson) return;
|
||||
copyText(detail.formattedJson);
|
||||
}
|
||||
|
||||
function copyDetailCurrentValue() {
|
||||
async function copyDetailCurrentValue() {
|
||||
if (sideDetailJsonView.value && activeCellDetail.value?.formattedJson) {
|
||||
copyDetailFormattedJson();
|
||||
await copyDetailFormattedJson();
|
||||
} else {
|
||||
copyDetailValue();
|
||||
await copyDetailValue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7343,7 +7592,9 @@ function copyDetailColumnName() {
|
||||
}
|
||||
|
||||
function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
|
||||
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
|
||||
if (!detail) return false;
|
||||
const item = getRowItem(detail.rowId);
|
||||
return (isLargeValuePreview(item, detail.colIndex) && isBinaryCellColumnType(detail.type)) || canDownloadBinaryCellValue(detail.value, detail.type);
|
||||
}
|
||||
|
||||
function canImportDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
|
||||
@@ -7390,10 +7641,13 @@ function downloadCellBinaryValue(rowIndex: number, columnIndex: number, mode: Bi
|
||||
async function downloadDetailBinaryValue(detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) {
|
||||
if (!detail || !canDownloadDetailBinaryValue(detail)) return;
|
||||
try {
|
||||
const payload = binaryCellDownloadPayload(detail.value, mode, detail.type);
|
||||
if (!(await hydrateLargeValueCell(detail.rowId, detail.colIndex))) return;
|
||||
const resolvedDetail = cellDetailFor(detail.rowNumber - 1, detail.colIndex);
|
||||
if (!resolvedDetail) return;
|
||||
const payload = binaryCellDownloadPayload(resolvedDetail.value, mode, resolvedDetail.type);
|
||||
const fileName = binaryCellDownloadFileName({
|
||||
column: detail.column,
|
||||
rowNumber: detail.rowNumber,
|
||||
column: resolvedDetail.column,
|
||||
rowNumber: resolvedDetail.rowNumber,
|
||||
mode,
|
||||
extension: payload.extension,
|
||||
});
|
||||
@@ -7444,35 +7698,102 @@ async function openDialogCellInSidePanel() {
|
||||
showCellDetails(detail.rowNumber - 1, detail.colIndex);
|
||||
cellDetailDialogOpen.value = false;
|
||||
await nextTick();
|
||||
if (detail.isEditable) startDetailEdit();
|
||||
if (detail.isEditable) await startDetailEdit();
|
||||
}
|
||||
|
||||
function copyRowDetailJson() {
|
||||
const detail = rowDetail.value;
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailJson(detail));
|
||||
async function resolvedRowDetailForCopy() {
|
||||
if (rowDetailDialogRowId.value === null) return null;
|
||||
const item = getRowItem(rowDetailDialogRowId.value);
|
||||
if (!item) return null;
|
||||
const resolved = await resolveLargeValueCells([item.id], visibleColumnIndexes.value);
|
||||
const data = [...item.data];
|
||||
for (const [columnIndex, value] of resolved.get(item.id) ?? []) data[columnIndex] = value;
|
||||
return buildDataGridRowDetail({
|
||||
rowIndex: item.displayIndex,
|
||||
rowId: item.id,
|
||||
row: data,
|
||||
columns: props.result.columns,
|
||||
columnIndexes: visibleColumnIndexes.value,
|
||||
typeByColumn: columnTypeMap.value,
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
isEditableColumn: (columnIndex) => canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft }),
|
||||
});
|
||||
}
|
||||
|
||||
function copyRowDetailTsv() {
|
||||
const detail = rowDetail.value;
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailTsv(detail));
|
||||
async function resolvedColumnDetailForCopy() {
|
||||
if (columnDetailDialogColumnIndex.value === null) return null;
|
||||
const columnIndex = columnDetailDialogColumnIndex.value;
|
||||
const items = displayItems.value.filter((item) => !item.isDraft);
|
||||
const resolved = await resolveLargeValueCells(
|
||||
items.map((item) => item.id),
|
||||
[columnIndex],
|
||||
);
|
||||
return buildDataGridColumnDetail({
|
||||
rows: items.map((item) => {
|
||||
const row = [...item.data];
|
||||
if (resolved.get(item.id)?.has(columnIndex)) row[columnIndex] = resolved.get(item.id)!.get(columnIndex) ?? null;
|
||||
return {
|
||||
rowIndex: item.displayIndex,
|
||||
rowId: item.id,
|
||||
row,
|
||||
isEditable: canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft }),
|
||||
};
|
||||
}),
|
||||
columns: props.result.columns,
|
||||
columnIndex,
|
||||
typeByColumn: columnTypeMap.value,
|
||||
resultColumnTypes: props.result.column_types,
|
||||
commentByColumn: columnCommentMap.value,
|
||||
displayValue: (value, index) => formatCellCached(value, index),
|
||||
});
|
||||
}
|
||||
|
||||
function copyRowDetailFieldValue(field: DataGridCellDetail) {
|
||||
copyText(field.value === null ? "" : displayCellValue(field.value));
|
||||
async function copyRowDetailJson() {
|
||||
try {
|
||||
const detail = await resolvedRowDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailJson(detail));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function copyColumnDetailJson() {
|
||||
const detail = columnDetail.value;
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailJson(detail));
|
||||
async function copyRowDetailTsv() {
|
||||
try {
|
||||
const detail = await resolvedRowDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridRowDetailTsv(detail));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function copyColumnDetailTsv() {
|
||||
const detail = columnDetail.value;
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailTsv(detail));
|
||||
async function copyRowDetailFieldValue(field: DataGridCellDetail) {
|
||||
if (!(await hydrateLargeValueCell(field.rowId, field.colIndex))) return;
|
||||
const resolved = cellDetailFor(field.rowNumber - 1, field.colIndex);
|
||||
if (resolved) copyText(resolved.value === null ? "" : displayCellValue(resolved.value));
|
||||
}
|
||||
|
||||
async function copyColumnDetailJson() {
|
||||
try {
|
||||
const detail = await resolvedColumnDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailJson(detail));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyColumnDetailTsv() {
|
||||
try {
|
||||
const detail = await resolvedColumnDetailForCopy();
|
||||
if (!detail) return;
|
||||
copyText(dataGridColumnDetailTsv(detail));
|
||||
} catch (error) {
|
||||
reportLargeValueLoadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function copyColumnDetailColumnName() {
|
||||
@@ -7481,8 +7802,10 @@ function copyColumnDetailColumnName() {
|
||||
copyText(detail.column);
|
||||
}
|
||||
|
||||
function copyColumnDetailFieldValue(field: DataGridCellDetail) {
|
||||
copyText(field.value === null ? "" : displayCellValue(field.value));
|
||||
async function copyColumnDetailFieldValue(field: DataGridCellDetail) {
|
||||
if (!(await hydrateLargeValueCell(field.rowId, field.colIndex))) return;
|
||||
const resolved = cellDetailFor(field.rowNumber - 1, field.colIndex);
|
||||
if (resolved) copyText(resolved.value === null ? "" : displayCellValue(resolved.value));
|
||||
}
|
||||
|
||||
const transposeRecordWidths = ref<number[]>([]);
|
||||
@@ -8705,7 +9028,7 @@ async function navigateToForeignKeyCell(rowIndex: number, actualColIdx: number)
|
||||
const association = cellForeignKeyAssociation(actualColIdx);
|
||||
const item = displayItems.value[rowIndex];
|
||||
if (!association || !item || !props.connectionId) return;
|
||||
const cells = foreignKeyAssociationCells({
|
||||
let cells = foreignKeyAssociationCells({
|
||||
association,
|
||||
context: props.context,
|
||||
resultColumns: props.result.columns,
|
||||
@@ -8714,6 +9037,15 @@ async function navigateToForeignKeyCell(rowIndex: number, actualColIdx: number)
|
||||
});
|
||||
if (!cells) return;
|
||||
try {
|
||||
const resolved = await resolveLargeValueCells(
|
||||
[item.id],
|
||||
cells.map((cell) => cell.columnIndex),
|
||||
);
|
||||
const resolvedRow = resolved.get(item.id);
|
||||
if (resolvedRow) {
|
||||
cells = cells.map((cell) => (resolvedRow.has(cell.columnIndex) ? { ...cell, value: resolvedRow.get(cell.columnIndex) ?? null } : cell));
|
||||
if (cells.some((cell) => cell.value === null)) throw new Error(t("grid.largeValueRowUnavailable"));
|
||||
}
|
||||
const conditions = await Promise.all(
|
||||
cells.map(({ foreignKey, value }) =>
|
||||
buildColumnValueFilterCondition({
|
||||
@@ -9461,7 +9793,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
labels: rowLabels,
|
||||
icons: { clone: CopyPlus, restore: Undo2, delete: Trash2 },
|
||||
actions: {
|
||||
clone: () => (isMultiRow.value ? cloneRows(affectedRowIds()) : row && cloneRow(row.id)),
|
||||
clone: () => void (isMultiRow.value ? cloneRows(affectedRowIds()) : row && cloneRow(row.id)),
|
||||
restore: () => (isMultiRow.value ? restoreRows(affectedRowIds()) : row && restoreRow(row.id)),
|
||||
delete: () => (isMultiRow.value ? requestDeleteRows(deletableRowIds(affectedRowIds())) : row && requestDeleteRow(row.id)),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({
|
||||
listIndexes: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
tableOpenPageSize: 100,
|
||||
infiniteScroll: true,
|
||||
queryResultMaxRowsEnabled: true,
|
||||
queryResultMaxRows: 10_000,
|
||||
tabs: [] as QueryTab[],
|
||||
setTableMeta: vi.fn(),
|
||||
clearInvalidDataTabSort: vi.fn(),
|
||||
@@ -80,7 +83,14 @@ vi.mock("@/stores/queryStore", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({ editorSettings: { tableOpenPageSize: mocks.tableOpenPageSize } }),
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
tableOpenPageSize: mocks.tableOpenPageSize,
|
||||
infiniteScroll: mocks.infiniteScroll,
|
||||
queryResultMaxRowsEnabled: mocks.queryResultMaxRowsEnabled,
|
||||
queryResultMaxRows: mocks.queryResultMaxRows,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
@@ -118,6 +128,9 @@ describe("useDataGridActions", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.tabs.length = 0;
|
||||
mocks.tableOpenPageSize = 100;
|
||||
mocks.infiniteScroll = true;
|
||||
mocks.queryResultMaxRowsEnabled = true;
|
||||
mocks.queryResultMaxRows = 10_000;
|
||||
mocks.getConfig.mockReturnValue({ id: "postgres-1", db_type: "postgres" });
|
||||
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0");
|
||||
mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" });
|
||||
@@ -160,6 +173,23 @@ describe("useDataGridActions", () => {
|
||||
expect(mocks.executeTabSql.mock.calls[0]?.[2]).not.toHaveProperty("preserveTotalRowCountDuringExecution");
|
||||
});
|
||||
|
||||
it("keeps infinite-scroll appends for ordinary table-data pages", async () => {
|
||||
const tab = tableDataTab({
|
||||
result: {
|
||||
columns: ["id"],
|
||||
rows: Array.from({ length: 100 }, (_, index) => [index + 1]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
},
|
||||
});
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(100, 100);
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 100 OFFSET 100", expect.objectContaining({ appendResult: { maxRows: 10_000 } }));
|
||||
});
|
||||
|
||||
it("ignores a stale structured order when its column was renamed", async () => {
|
||||
const tab = tableDataTab({
|
||||
resultSortColumn: "old_name",
|
||||
|
||||
@@ -243,4 +243,13 @@ describe("useDataGridEditor appendPastedRowsToNewRow", () => {
|
||||
["Grace", null, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses resolved full values instead of preview values when cloning", () => {
|
||||
const editor = createEditor();
|
||||
editor.newRows.value = [["Ada", "preview...", "Lovelace"]];
|
||||
|
||||
editor.cloneRow(-1, new Map([[1, "full payload"]]));
|
||||
|
||||
expect(editor.newRows.value[1]).toEqual(["Ada", "full payload", "Lovelace"]);
|
||||
});
|
||||
});
|
||||
@@ -763,6 +763,103 @@ describe("useDataGridExport prepared row statements", () => {
|
||||
expect(extractDataGridSelection).toHaveBeenNthCalledWith(2, expect.objectContaining({ rows: [[7, { name: "Ada", tags: ["admin"] }]] }));
|
||||
});
|
||||
|
||||
it("resolves large-value previews before building an extractor request", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [0, 1],
|
||||
columns: ["id", "name"],
|
||||
rows: [[7, "preview..."]],
|
||||
};
|
||||
const item = row([7, "preview..."]);
|
||||
const resolveSourceValues = vi.fn(async () => new Map([[item.id, new Map([[1, "full payload"]])]]));
|
||||
const options: UseDataGridExportOptions = {
|
||||
columns: computed(() => ["id", "name"]),
|
||||
displayItems: computed(() => [item]),
|
||||
allColumns: computed(() => ["id", "name"]),
|
||||
allDisplayItems: computed(() => [item]),
|
||||
visibleColumnIndexes: computed(() => [0, 1]),
|
||||
sql: computed(() => undefined),
|
||||
tableMeta: computed(() => editableTable),
|
||||
databaseType: computed(() => "mysql"),
|
||||
connectionId: computed(() => "connection-1"),
|
||||
database: computed(() => "dbx"),
|
||||
context: computed(() => "table-data"),
|
||||
sourceColumns: computed(() => ["id", "name"]),
|
||||
columnTypes: computed(() => ["int", "varchar"]),
|
||||
whereInput: computed(() => undefined),
|
||||
orderBy: computed(() => undefined),
|
||||
exportBatchSize: computed(() => 1000),
|
||||
hasCellSelection: computed(() => true),
|
||||
selectedCells: computed(() => matrix),
|
||||
selectedCellMatrix: computed(() => matrix),
|
||||
selectedRange: computed(() => ({ startRow: 0, endRow: 0, startCol: 0, endCol: 1 })),
|
||||
contextCell: ref({ rowId: item.id, rowIndex: 0, col: 1 }),
|
||||
contextSelectionIsSynthetic: ref(false),
|
||||
getRowItem: () => item,
|
||||
selectedRowIds: ref(new Set<number>()),
|
||||
hasRowSelection: computed(() => false),
|
||||
resolveSourceValues,
|
||||
};
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
|
||||
text: "copied",
|
||||
mimeType: "text/tab-separated-values",
|
||||
fileExtension: "tsv",
|
||||
rowCount: 1,
|
||||
columnCount: 2,
|
||||
});
|
||||
|
||||
await expect(useDataGridExport(options).copyWithExtractor("tsv")).resolves.toBe(true);
|
||||
|
||||
expect(resolveSourceValues).toHaveBeenCalledWith([item.id], [0, 1]);
|
||||
expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ rows: [[7, "full payload"]] }));
|
||||
});
|
||||
|
||||
it("does not copy a preview when full-value resolution fails", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [0],
|
||||
columns: ["name"],
|
||||
rows: [["preview..."]],
|
||||
};
|
||||
const item = row(["preview..."]);
|
||||
const state = useDataGridExport({
|
||||
columns: computed(() => ["name"]),
|
||||
displayItems: computed(() => [item]),
|
||||
allColumns: computed(() => ["name"]),
|
||||
allDisplayItems: computed(() => [item]),
|
||||
visibleColumnIndexes: computed(() => [0]),
|
||||
sql: computed(() => undefined),
|
||||
tableMeta: computed(() => editableTable),
|
||||
databaseType: computed(() => "mysql"),
|
||||
connectionId: computed(() => "connection-1"),
|
||||
database: computed(() => "dbx"),
|
||||
context: computed(() => "table-data"),
|
||||
sourceColumns: computed(() => ["name"]),
|
||||
columnTypes: computed(() => ["varchar"]),
|
||||
whereInput: computed(() => undefined),
|
||||
orderBy: computed(() => undefined),
|
||||
exportBatchSize: computed(() => 1000),
|
||||
hasCellSelection: computed(() => true),
|
||||
selectedCells: computed(() => matrix),
|
||||
selectedCellMatrix: computed(() => matrix),
|
||||
selectedRange: computed(() => ({ startRow: 0, endRow: 0, startCol: 0, endCol: 0 })),
|
||||
contextCell: ref({ rowId: item.id, rowIndex: 0, col: 0 }),
|
||||
contextSelectionIsSynthetic: ref(false),
|
||||
getRowItem: () => item,
|
||||
selectedRowIds: ref(new Set<number>()),
|
||||
hasRowSelection: computed(() => false),
|
||||
resolveSourceValues: async () => {
|
||||
throw new Error("stable key required");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(state.copyWithExtractor("tsv")).resolves.toBe(false);
|
||||
|
||||
expect(extractDataGridSelection).not.toHaveBeenCalled();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith("grid.copyFailed: stable key required", 5000);
|
||||
});
|
||||
|
||||
it("rejects SQL UPDATE instead of silently skipping a row with a null primary key", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
|
||||
@@ -212,11 +212,13 @@ describe("useSidebarDataOpenRuntime", () => {
|
||||
await useSidebarDataOpenRuntime().openData(tableNode);
|
||||
mocks.tabs[0]!.isExecuting = false;
|
||||
mocks.tabs[0]!.executionId = undefined;
|
||||
mocks.tabs[0]!.resultLocalSortOriginalLargeValueCells = [{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }];
|
||||
await useSidebarDataOpenRuntime().openData(ordersNode);
|
||||
|
||||
expect(mocks.tabs).toHaveLength(1);
|
||||
expect(mocks.tabs[0]?.title).toBe("orders");
|
||||
expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("orders");
|
||||
expect(mocks.tabs[0]?.resultLocalSortOriginalLargeValueCells).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1120,12 +1120,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
return { ok: true, rowCount: mappedRows.length };
|
||||
}
|
||||
|
||||
function clonedRowData(item: RowItem): CellValue[] {
|
||||
function clonedRowData(item: RowItem, resolvedValues?: ReadonlyMap<number, CellValue>): CellValue[] {
|
||||
const columnInfoByName = new Map((tableMeta.value?.columns ?? []).map((column) => [column.name.toLowerCase(), column]));
|
||||
return item.data.map((val, i) => {
|
||||
const columnName = sourceColumns.value?.[i] ?? result.value.columns[i];
|
||||
const columnInfo = columnInfoByName.get(columnName.toLowerCase());
|
||||
return shouldClearClonedColumn(columnName, columnInfo) ? null : val;
|
||||
if (shouldClearClonedColumn(columnName, columnInfo)) return null;
|
||||
return resolvedValues?.has(i) ? (resolvedValues.get(i) ?? null) : val;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1137,10 +1138,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
return /\b(auto_increment|autoincrement|identity|generated)\b/i.test(extra) || /\bnextval\s*\(/i.test(columnDefault);
|
||||
}
|
||||
|
||||
function cloneRow(rowId: number) {
|
||||
function cloneRow(rowId: number, resolvedValues?: ReadonlyMap<number, CellValue>) {
|
||||
const item = getRowItem(rowId);
|
||||
if (!item) return;
|
||||
const clonedData = clonedRowData(item);
|
||||
const clonedData = clonedRowData(item, resolvedValues);
|
||||
pushUndoSnapshot();
|
||||
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
|
||||
newRows.value.push(clonedData);
|
||||
@@ -1159,13 +1160,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
function cloneRows(rowIds: number[]) {
|
||||
function cloneRows(rowIds: number[], resolvedValues?: ReadonlyMap<number, ReadonlyMap<number, CellValue>>) {
|
||||
const rowsToClone = rowIds.map((rowId) => getRowItem(rowId)).filter(Boolean) as RowItem[];
|
||||
if (rowsToClone.length === 0) return;
|
||||
pushUndoSnapshot();
|
||||
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
|
||||
for (const item of rowsToClone) {
|
||||
const clonedData = clonedRowData(item);
|
||||
const clonedData = clonedRowData(item, resolvedValues?.get(item.id));
|
||||
newRows.value.push(clonedData);
|
||||
newRowMeta.value.push(clonedRowMeta(item, clonedData));
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface UseDataGridExportOptions {
|
||||
getRowItem: (rowId: number) => RowItem | undefined;
|
||||
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
|
||||
hasRowSelection: ComputedRef<boolean>;
|
||||
resolveSourceValues?: (rowIds: number[], sourceColumnIndexes: number[]) => Promise<Map<number, Map<number, CellValue>>>;
|
||||
fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>;
|
||||
queryResultExportRequest?: (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined> }) => Promise<QueryResultExportRequest | undefined>;
|
||||
/**
|
||||
@@ -182,6 +183,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
getRowItem,
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
resolveSourceValues,
|
||||
fullExportResult,
|
||||
queryResultExportRequest,
|
||||
hasCompleteLocalResult,
|
||||
@@ -223,6 +225,25 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
return displayItems.value.filter((item) => rowIdSet.has(item.id) && !item.isDraft);
|
||||
}
|
||||
|
||||
async function resolveVisibleRowValues(items: RowItem[], visibleIndexes = visibleColumnIndexes.value): Promise<RowItem[]> {
|
||||
if (!resolveSourceValues || items.length === 0 || visibleIndexes.length === 0) return items;
|
||||
const resolved = await resolveSourceValues(
|
||||
items.map((item) => item.id),
|
||||
visibleIndexes,
|
||||
);
|
||||
if (resolved.size === 0) return items;
|
||||
return items.map((item) => {
|
||||
const values = resolved.get(item.id);
|
||||
if (!values) return item;
|
||||
const data = [...item.data];
|
||||
visibleIndexes.forEach((sourceIndex) => {
|
||||
const visibleIndex = visibleColumnIndexes.value.indexOf(sourceIndex);
|
||||
if (visibleIndex >= 0 && values.has(sourceIndex)) data[visibleIndex] = values.get(sourceIndex) ?? null;
|
||||
});
|
||||
return { ...item, data };
|
||||
});
|
||||
}
|
||||
|
||||
function applyGlobalDateTimeExportFormat(result: { columns: string[]; columnTypes: string[]; rows: CellValue[][] }, enabled: boolean) {
|
||||
const pattern = enabled ? useSettingsStore().editorSettings.globalDateTimeExportFormat : "";
|
||||
return pattern ? { ...result, rows: formatTemporalRowsForExport(result.rows, result.columnTypes, pattern) } : result;
|
||||
@@ -315,7 +336,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
{
|
||||
columns: columns.value,
|
||||
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
rows: (await resolveVisibleRowValues(rowsToExport(rowIds))).map((item) => item.data),
|
||||
},
|
||||
formatDateTime,
|
||||
),
|
||||
@@ -436,7 +457,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
async function copyRowsAsJson(items: RowItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject);
|
||||
const resolvedItems = await resolveVisibleRowValues(items);
|
||||
const value = resolvedItems.length === 1 ? rowToJsonObject(resolvedItems[0]) : resolvedItems.map(rowToJsonObject);
|
||||
const hasOriginalMongoDocuments = options.databaseType.value === "mongodb" && items.every((item) => item.sourceIndex !== undefined && options.mongoDocuments?.value?.[item.sourceIndex] !== undefined);
|
||||
const copyValue = options.databaseType.value === "mongodb" && !hasOriginalMongoDocuments ? expandNestedJsonStringsForCopy(value) : value;
|
||||
await copyText(JSON.stringify(copyValue, null, 2));
|
||||
@@ -447,7 +469,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!contextCell.value || contextCell.value.col < 0) return;
|
||||
const item = getRowItem(contextCell.value.rowId);
|
||||
if (!item || item.isDraft) return;
|
||||
const val = item?.data[contextCell.value.col] ?? null;
|
||||
const sourceIndex = visibleColumnIndexes.value[contextCell.value.col] ?? contextCell.value.col;
|
||||
const [resolvedItem] = await resolveVisibleRowValues([item], [sourceIndex]);
|
||||
const val = resolvedItem?.data[contextCell.value.col] ?? null;
|
||||
await copyText(displayCellValue(val), { rows: [[val]] });
|
||||
}
|
||||
|
||||
@@ -575,6 +599,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
hasRowSelection,
|
||||
hasColumnSelection,
|
||||
selectedRowIds,
|
||||
resolveSourceValues,
|
||||
copyText,
|
||||
canCopySqlInsert: (request) => {
|
||||
const selectedColumns = request.selectedColumnIndexes.map((index) => request.columns[index]?.sourceName ?? request.columns[index]?.displayName).filter((column): column is string => !!column);
|
||||
@@ -589,7 +614,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
async function copyAll() {
|
||||
const header = columns.value.join("\t");
|
||||
const rows = displayItems.value.filter((item) => !item.isDraft).map((item) => item.data);
|
||||
const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data);
|
||||
const body = rows.map((row) => row.map((cell) => displayCellValue(cell)).join("\t")).join("\n");
|
||||
await copyText(`${header}\n${body}`, { rows, header: columns.value });
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface UseDataGridExtractorOptions {
|
||||
hasRowSelection: ComputedRef<boolean>;
|
||||
hasColumnSelection: ComputedRef<boolean>;
|
||||
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
|
||||
resolveSourceValues?: (rowIds: number[], sourceColumnIndexes: number[]) => Promise<Map<number, Map<number, unknown>>>;
|
||||
contextCell: ComputedRef<{ rowId: number; rowIndex: number; col: number } | null> | Ref<{ rowId: number; rowIndex: number; col: number } | null>;
|
||||
contextSelectionIsSynthetic: ComputedRef<boolean> | Ref<boolean>;
|
||||
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
|
||||
@@ -58,6 +59,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const hasUnsupportedDiscreteSelection = computed(() => options.hasCellSelection.value && options.selectedCellMatrix.value === null);
|
||||
const requestSources = new WeakMap<DataGridExtractRequest, { rowIds: number[]; sourceColumnIndexes: number[]; columnTypes: Array<string | undefined>; normalizeValues: boolean }>();
|
||||
|
||||
function normalizeCellValue(value: unknown, columnType: string | undefined): unknown {
|
||||
if (typeof value !== "string" || columnType?.trim().toLowerCase() !== "json") return value;
|
||||
@@ -82,6 +84,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const fullColumns = options.allColumns.value;
|
||||
const fullItemsById = new Map(options.allDisplayItems.value.map((item) => [item.id, item]));
|
||||
let sourceRows: unknown[][] = [];
|
||||
let sourceRowIds: number[] = [];
|
||||
let selectedSourceIndexes: number[] = [];
|
||||
let selectionKind: DataGridExtractRequest["selectionKind"] = "cells";
|
||||
|
||||
@@ -93,14 +96,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const hasRightClickContext = !!options.contextCell.value && options.contextSelectionIsSynthetic.value;
|
||||
|
||||
if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
|
||||
sourceRows = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
const items = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft);
|
||||
sourceRows = items.map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
sourceRowIds = items.map((item) => item.id);
|
||||
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
|
||||
selectionKind = "rows";
|
||||
} else if (isMultiCellSelection && matrix) {
|
||||
sourceRows = matrix.rowIndexes
|
||||
.map((rowIndex) => options.displayItems.value[rowIndex])
|
||||
.filter((item): item is ExtractorRowItem => !!item && !item.isDraft)
|
||||
.map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
const items = matrix.rowIndexes.map((rowIndex) => options.displayItems.value[rowIndex]).filter((item): item is ExtractorRowItem => !!item && !item.isDraft);
|
||||
sourceRows = items.map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
sourceRowIds = items.map((item) => item.id);
|
||||
selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length);
|
||||
if (options.hasColumnSelection.value) selectionKind = "columns";
|
||||
} else if (hasRightClickContext && options.contextCell.value) {
|
||||
@@ -108,15 +112,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const item = fullItemsById.get(options.contextCell.value.rowId);
|
||||
if (item && !item.isDraft) {
|
||||
sourceRows = [item.data];
|
||||
sourceRowIds = [item.id];
|
||||
selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length);
|
||||
selectionKind = "rows";
|
||||
}
|
||||
} else if (matrix) {
|
||||
// Single-cell matrix without a context cell — still honor the explicit selection.
|
||||
sourceRows = matrix.rowIndexes
|
||||
.map((rowIndex) => options.displayItems.value[rowIndex])
|
||||
.filter((item): item is ExtractorRowItem => !!item && !item.isDraft)
|
||||
.map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
const items = matrix.rowIndexes.map((rowIndex) => options.displayItems.value[rowIndex]).filter((item): item is ExtractorRowItem => !!item && !item.isDraft);
|
||||
sourceRows = items.map((item) => (fullItemsById.get(item.id) ?? item).data);
|
||||
sourceRowIds = items.map((item) => item.id);
|
||||
selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length);
|
||||
if (options.hasColumnSelection.value) selectionKind = "columns";
|
||||
}
|
||||
@@ -146,7 +150,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
columns.map((column) => column.sourceName ?? column.displayName),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
const request: DataGridExtractRequest = {
|
||||
version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
|
||||
extractor,
|
||||
databaseType: descriptor.category === "sql" ? options.databaseType.value : undefined,
|
||||
@@ -157,6 +161,31 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
selectionKind,
|
||||
options: normalizeDataGridExtractorOptions(extractorOptions),
|
||||
};
|
||||
requestSources.set(request, {
|
||||
rowIds: sourceRowIds,
|
||||
sourceColumnIndexes: requiredSourceIndexes,
|
||||
columnTypes: requiredSourceIndexes.map((sourceIndex) => columnTypesBySource.get(sourceIndex)),
|
||||
normalizeValues: descriptor.category === "json" || descriptor.category === "sql",
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
async function resolveRequestSourceValues(request: DataGridExtractRequest, rowLimit?: number): Promise<DataGridExtractRequest> {
|
||||
const source = requestSources.get(request);
|
||||
if (!source || !options.resolveSourceValues) return rowLimit === undefined ? request : { ...request, rows: request.rows.slice(0, rowLimit) };
|
||||
const rowIds = rowLimit === undefined ? source.rowIds : source.rowIds.slice(0, rowLimit);
|
||||
const overlays = await options.resolveSourceValues(rowIds, source.sourceColumnIndexes);
|
||||
const rows = request.rows.slice(0, rowIds.length).map((row, rowIndex) => {
|
||||
const values = overlays.get(rowIds[rowIndex]!);
|
||||
if (!values) return row;
|
||||
return row.map((value, compactIndex) => {
|
||||
const sourceIndex = source.sourceColumnIndexes[compactIndex];
|
||||
if (sourceIndex === undefined || !values.has(sourceIndex)) return value;
|
||||
const resolved = values.get(sourceIndex);
|
||||
return source.normalizeValues ? normalizeCellValue(resolved, source.columnTypes[compactIndex]) : resolved;
|
||||
});
|
||||
});
|
||||
return { ...request, rows };
|
||||
}
|
||||
|
||||
function canBuildSqlUpdateRequest(): boolean {
|
||||
@@ -219,9 +248,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
return false;
|
||||
}
|
||||
if (!canCopyWithExtractor(extractor, extractorOptions)) return false;
|
||||
const request = buildRequest(extractor, extractorOptions);
|
||||
if (!request) return false;
|
||||
const initialRequest = buildRequest(extractor, extractorOptions);
|
||||
if (!initialRequest) return false;
|
||||
try {
|
||||
const request = await resolveRequestSourceValues(initialRequest);
|
||||
const mongoResult = await resolveMongoExtractorResult(extractor, request);
|
||||
const result = mongoResult ?? (await api.extractDataGridSelection(request));
|
||||
if (!result.text) return false;
|
||||
@@ -253,12 +283,13 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
|
||||
async function previewWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
|
||||
if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
|
||||
const request = buildRequest(extractor, extractorOptions);
|
||||
if (!request) throw new Error(t("grid.copyExtractorEmptySelection"));
|
||||
const sourceRowCount = request.rows.length;
|
||||
const initialRequest = buildRequest(extractor, extractorOptions);
|
||||
if (!initialRequest) throw new Error(t("grid.copyExtractorEmptySelection"));
|
||||
const sourceRowCount = initialRequest.rows.length;
|
||||
const previewRowCount = Math.min(sourceRowCount, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS);
|
||||
const request = await resolveRequestSourceValues(initialRequest, previewRowCount);
|
||||
const mongoResult = await resolveMongoExtractorResult(extractor, request, previewRowCount);
|
||||
const result = mongoResult ?? (await api.extractDataGridSelection({ ...request, rows: request.rows.slice(0, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS) }));
|
||||
const result = mongoResult ?? (await api.extractDataGridSelection(request));
|
||||
if (!result.text) throw new Error(t("grid.copyExtractorEmptySelection"));
|
||||
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ export function useSidebarDataOpenRuntime() {
|
||||
tab.resultSortDirection = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
tab.resultSortedSql = undefined;
|
||||
|
||||
@@ -1818,6 +1818,12 @@ export default {
|
||||
detailSearchNoMatch: "No matches",
|
||||
rawValue: "Raw Value",
|
||||
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
|
||||
largeValueRowUnavailable: "The row could not be located in the database. The operation was canceled without using the preview value.",
|
||||
largeValueColumnUnavailable: "The source column for this large value could not be located.",
|
||||
largeValueLoadFailed: "Failed to load the full large-field value.",
|
||||
largeValueNeedsStableKey: "This table has no usable primary key, so the full large-field value cannot be loaded safely. The operation was canceled.",
|
||||
largeValueLoadFailedWithMessage: "Failed to load the full large-field value: {message}",
|
||||
largeValueLocalSortUnavailable: "This column contains large-value previews and cannot be sorted locally by preview content. Use database sorting instead.",
|
||||
copyValue: "Copy Value",
|
||||
importBinaryValue: "Import from File",
|
||||
binaryImportApplied: "Imported {count} bytes. Save changes to write them to the database.",
|
||||
|
||||
@@ -1686,6 +1686,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "Sin coincidencias",
|
||||
rawValue: "Valor sin procesar",
|
||||
largeValuePreviewHint: "Mostrando los primeros {count} caracteres. Copiar sigue usando el valor completo.",
|
||||
largeValueRowUnavailable: "No se pudo localizar la fila en la base de datos. La operación se canceló sin usar el valor de vista previa.",
|
||||
largeValueColumnUnavailable: "No se pudo localizar la columna de origen de este valor grande.",
|
||||
largeValueLoadFailed: "No se pudo cargar el valor completo del campo grande.",
|
||||
largeValueNeedsStableKey: "Esta tabla no tiene una clave primaria utilizable, por lo que el valor completo del campo grande no se puede cargar de forma segura. La operación se canceló.",
|
||||
largeValueLoadFailedWithMessage: "No se pudo cargar el valor completo del campo grande: {message}",
|
||||
largeValueLocalSortUnavailable: "Esta columna contiene vistas previas de valores grandes y no se puede ordenar localmente por su contenido. Use la ordenación de la base de datos.",
|
||||
copyValue: "Copiar valor",
|
||||
downloadBinaryValue: "Descargar valor",
|
||||
downloadSaved: "Guardado en {path}",
|
||||
|
||||
@@ -1684,6 +1684,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "Nessun risultato",
|
||||
rawValue: "Valore Grezzo",
|
||||
largeValuePreviewHint: "Anteprima dei primi {count} caratteri. La copia utilizzerà comunque il valore completo.",
|
||||
largeValueRowUnavailable: "Impossibile individuare la riga nel database. L'operazione è stata annullata senza usare il valore di anteprima.",
|
||||
largeValueColumnUnavailable: "Impossibile individuare la colonna di origine per questo valore di grandi dimensioni.",
|
||||
largeValueLoadFailed: "Impossibile caricare il valore completo del campo di grandi dimensioni.",
|
||||
largeValueNeedsStableKey: "Questa tabella non dispone di una chiave primaria utilizzabile, quindi il valore completo del campo non può essere caricato in modo sicuro. L'operazione è stata annullata.",
|
||||
largeValueLoadFailedWithMessage: "Impossibile caricare il valore completo del campo di grandi dimensioni: {message}",
|
||||
largeValueLocalSortUnavailable: "Questa colonna contiene anteprime di valori di grandi dimensioni e non può essere ordinata localmente in base all'anteprima. Usa l'ordinamento del database.",
|
||||
copyValue: "Copia Valore",
|
||||
downloadBinaryValue: "Scarica Valore",
|
||||
downloadSaved: "Salvato in {path}",
|
||||
|
||||
@@ -1702,6 +1702,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "一致なし",
|
||||
rawValue: "生の値",
|
||||
largeValuePreviewHint: "先頭{count}文字をプレビュー表示中です。コピー時は完全な値が使用されます。",
|
||||
largeValueRowUnavailable: "データベース内で行を特定できません。プレビュー値を使用せず、操作をキャンセルしました。",
|
||||
largeValueColumnUnavailable: "大きな値に対応するソース列を特定できません。",
|
||||
largeValueLoadFailed: "大きなフィールドの完全な値を読み込めませんでした。",
|
||||
largeValueNeedsStableKey: "このテーブルには使用可能な主キーがないため、大きなフィールドの完全な値を安全に読み込めません。操作をキャンセルしました。",
|
||||
largeValueLoadFailedWithMessage: "大きなフィールドの完全な値を読み込めませんでした: {message}",
|
||||
largeValueLocalSortUnavailable: "この列には大きな値のプレビューが含まれるため、プレビュー内容による現在のページの並べ替えはできません。データベース並べ替えを使用してください。",
|
||||
copyValue: "値をコピー",
|
||||
downloadBinaryValue: "値をダウンロード",
|
||||
downloadSaved: "{path} に保存しました",
|
||||
|
||||
@@ -1688,6 +1688,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "일치 항목 없음",
|
||||
rawValue: "원시 값",
|
||||
largeValuePreviewHint: "처음 {count}자 미리보는 중. 복사에는 여전히 전체 값이 사용됩니다.",
|
||||
largeValueRowUnavailable: "데이터베이스에서 행을 찾을 수 없습니다. 미리보기 값을 사용하지 않고 작업을 취소했습니다.",
|
||||
largeValueColumnUnavailable: "큰 값에 해당하는 원본 열을 찾을 수 없습니다.",
|
||||
largeValueLoadFailed: "큰 필드의 전체 값을 불러오지 못했습니다.",
|
||||
largeValueNeedsStableKey: "이 테이블에는 사용할 수 있는 기본 키가 없어 큰 필드의 전체 값을 안전하게 불러올 수 없습니다. 작업을 취소했습니다.",
|
||||
largeValueLoadFailedWithMessage: "큰 필드의 전체 값을 불러오지 못했습니다: {message}",
|
||||
largeValueLocalSortUnavailable: "이 열에는 큰 값 미리보기가 포함되어 있어 미리보기 내용으로 현재 페이지를 정렬할 수 없습니다. 데이터베이스 정렬을 사용하세요.",
|
||||
copyValue: "값 복사",
|
||||
downloadBinaryValue: "값 다운로드",
|
||||
binaryImportTooLarge: "파일이 너무 큽니다({size}, 상한 {limit}). 큰 파일을 단일 셀에 가져오는 것은 지원되지 않습니다.",
|
||||
|
||||
@@ -1686,6 +1686,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "Nenhuma correspondência",
|
||||
rawValue: "Valor Bruto",
|
||||
largeValuePreviewHint: "Visualizando os primeiros {count} caracteres. A cópia ainda usa o valor completo.",
|
||||
largeValueRowUnavailable: "Não foi possível localizar a linha no banco de dados. A operação foi cancelada sem usar o valor de visualização.",
|
||||
largeValueColumnUnavailable: "Não foi possível localizar a coluna de origem deste valor grande.",
|
||||
largeValueLoadFailed: "Falha ao carregar o valor completo do campo grande.",
|
||||
largeValueNeedsStableKey: "Esta tabela não tem uma chave primária utilizável, portanto o valor completo do campo grande não pode ser carregado com segurança. A operação foi cancelada.",
|
||||
largeValueLoadFailedWithMessage: "Falha ao carregar o valor completo do campo grande: {message}",
|
||||
largeValueLocalSortUnavailable: "Esta coluna contém visualizações de valores grandes e não pode ser ordenada localmente pelo conteúdo da visualização. Use a ordenação do banco de dados.",
|
||||
copyValue: "Copiar Valor",
|
||||
downloadBinaryValue: "Baixar Valor",
|
||||
downloadSaved: "Salvo em {path}",
|
||||
|
||||
@@ -1744,6 +1744,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "无匹配项",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
||||
largeValueRowUnavailable: "无法在数据库中定位该行,操作已取消,未使用预览值。",
|
||||
largeValueColumnUnavailable: "无法定位大字段对应的源列。",
|
||||
largeValueLoadFailed: "读取大字段完整值失败。",
|
||||
largeValueNeedsStableKey: "该表没有可用的主键,无法安全读取大字段完整值,操作已取消。",
|
||||
largeValueLoadFailedWithMessage: "读取大字段完整值失败:{message}",
|
||||
largeValueLocalSortUnavailable: "该列包含大字段预览,无法按预览内容进行当前页排序,请使用数据库排序。",
|
||||
copyValue: "复制值",
|
||||
importBinaryValue: "从文件导入",
|
||||
binaryImportApplied: "已导入 {count} 字节,保存更改后写入数据库。",
|
||||
|
||||
@@ -1686,6 +1686,12 @@ export default withEnglishFallback({
|
||||
detailSearchNoMatch: "無相符項目",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
||||
largeValueRowUnavailable: "無法在資料庫中定位該列,操作已取消,未使用預覽值。",
|
||||
largeValueColumnUnavailable: "無法定位大欄位對應的來源欄。",
|
||||
largeValueLoadFailed: "讀取大欄位完整值失敗。",
|
||||
largeValueNeedsStableKey: "該資料表沒有可用的主鍵,無法安全讀取大欄位完整值,操作已取消。",
|
||||
largeValueLoadFailedWithMessage: "讀取大欄位完整值失敗:{message}",
|
||||
largeValueLocalSortUnavailable: "該欄包含大欄位預覽,無法依預覽內容進行目前頁排序,請使用資料庫排序。",
|
||||
copyValue: "複製值",
|
||||
importBinaryValue: "從檔案匯入",
|
||||
binaryImportApplied: "已匯入 {count} 位元組,儲存變更後寫入資料庫。",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendLargeValueCells, largeValueCellMap, remapLargeValueCells } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { buildDataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
describe("data grid large-value metadata", () => {
|
||||
it("offsets appended segment rows and discards metadata beyond the append cap", () => {
|
||||
expect(
|
||||
appendLargeValueCells(
|
||||
[{ row_index: 0, column_index: 1, original_bytes: 10_000 }],
|
||||
[
|
||||
{ row_index: 0, column_index: 1, original_bytes: 20_000 },
|
||||
{ row_index: 1, column_index: 2, original_bytes: 30_000 },
|
||||
{ row_index: 2, column_index: 1, original_bytes: 40_000 },
|
||||
],
|
||||
2,
|
||||
2,
|
||||
),
|
||||
).toEqual([
|
||||
{ row_index: 0, column_index: 1, original_bytes: 10_000 },
|
||||
{ row_index: 2, column_index: 1, original_bytes: 20_000 },
|
||||
{ row_index: 3, column_index: 2, original_bytes: 30_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("remaps metadata to locally sorted row positions", () => {
|
||||
expect(
|
||||
remapLargeValueCells(
|
||||
[
|
||||
{ row_index: 0, column_index: 1, original_bytes: 10_000 },
|
||||
{ row_index: 2, column_index: 1, original_bytes: 30_000 },
|
||||
],
|
||||
[2, 0, 1],
|
||||
),
|
||||
).toEqual([
|
||||
{ row_index: 1, column_index: 1, original_bytes: 10_000 },
|
||||
{ row_index: 0, column_index: 1, original_bytes: 30_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("indexes metadata by source row and column", () => {
|
||||
const result = { large_value_cells: [{ row_index: 4, column_index: 2, original_bytes: 65_536 }] };
|
||||
expect(largeValueCellMap(result).get("4:2")).toEqual(result.large_value_cells[0]);
|
||||
});
|
||||
|
||||
it("marks a backend-bounded value as truncated even when the preview is shorter than the UI limit", () => {
|
||||
const detail = buildDataGridCellDetail({
|
||||
rowIndex: 0,
|
||||
rowId: 1,
|
||||
row: ["preview..."],
|
||||
columns: ["payload"],
|
||||
columnIndex: 0,
|
||||
displayValue: String,
|
||||
isEditable: true,
|
||||
isValuePreviewTruncated: true,
|
||||
});
|
||||
|
||||
expect(detail?.isValuePreviewTruncated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,25 @@ describe("tab result cache statement execution metadata", () => {
|
||||
expect(result?.spatial_values).toEqual([[4326], [3857], [null]]);
|
||||
});
|
||||
|
||||
it("preserves large-value preview metadata", () => {
|
||||
const encoded = encodeTabResultSnapshot({
|
||||
result: {
|
||||
columns: ["id", "payload"],
|
||||
rows: [[1, "preview..."]],
|
||||
large_value_cells: [{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
},
|
||||
resultLocalSortOriginalRows: [[1, "preview..."]],
|
||||
resultLocalSortOriginalLargeValueCells: [{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }],
|
||||
cachedAt: 1,
|
||||
});
|
||||
|
||||
const restored = decodeTabResultSnapshot(encoded);
|
||||
expect(restored?.result?.large_value_cells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }]);
|
||||
expect(restored?.resultLocalSortOriginalLargeValueCells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }]);
|
||||
});
|
||||
|
||||
it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => {
|
||||
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot()));
|
||||
|
||||
|
||||
@@ -927,6 +927,8 @@ export async function executeMulti(
|
||||
catalog?: string;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
maxResultBytes?: number;
|
||||
resultKeyColumns?: string[];
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
@@ -967,6 +969,8 @@ export async function executeMultiWithProgress(
|
||||
catalog?: string;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
maxResultBytes?: number;
|
||||
resultKeyColumns?: string[];
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
|
||||
@@ -1145,6 +1145,8 @@ export async function executeMulti(
|
||||
catalog?: string;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
maxResultBytes?: number;
|
||||
resultKeyColumns?: string[];
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
@@ -1189,6 +1191,8 @@ export async function executeMultiWithProgress(
|
||||
catalog?: string;
|
||||
fetchSize?: number;
|
||||
pageSize?: number;
|
||||
maxResultBytes?: number;
|
||||
resultKeyColumns?: string[];
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface BuildDataGridCellDetailOptions {
|
||||
displayValue: (value: CellValue, columnIndex: number) => string;
|
||||
isEditable: boolean;
|
||||
includeBinaryImagePreview?: boolean;
|
||||
isValuePreviewTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildDataGridRowDetailOptions {
|
||||
@@ -62,6 +63,7 @@ export interface BuildDataGridRowDetailOptions {
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
displayValue: (value: CellValue, columnIndex: number) => string;
|
||||
isEditableColumn?: (columnIndex: number) => boolean;
|
||||
isValuePreviewTruncated?: (columnIndex: number) => boolean;
|
||||
}
|
||||
|
||||
export interface BuildDataGridColumnDetailRow {
|
||||
@@ -69,6 +71,7 @@ export interface BuildDataGridColumnDetailRow {
|
||||
rowId: number;
|
||||
row: readonly CellValue[];
|
||||
isEditable?: boolean;
|
||||
isValuePreviewTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildDataGridColumnDetailOptions {
|
||||
@@ -105,7 +108,7 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
|
||||
rawValuePreview,
|
||||
displayValue,
|
||||
displayValuePreview,
|
||||
isValuePreviewTruncated: rawValuePreview.length < rawValue.length || displayValuePreview.length < displayValue.length,
|
||||
isValuePreviewTruncated: options.isValuePreviewTruncated === true || rawValuePreview.length < rawValue.length || displayValuePreview.length < displayValue.length,
|
||||
imagePreviewUrl: cellImagePreviewUrl(value, type, { binary: options.includeBinaryImagePreview !== false }),
|
||||
length: value === null ? 0 : String(value).length,
|
||||
formattedJson,
|
||||
@@ -131,6 +134,7 @@ export function buildDataGridColumnDetail(options: BuildDataGridColumnDetailOpti
|
||||
displayValue: options.displayValue,
|
||||
isEditable: row.isEditable ?? false,
|
||||
includeBinaryImagePreview: false,
|
||||
isValuePreviewTruncated: row.isValuePreviewTruncated,
|
||||
}),
|
||||
)
|
||||
.filter((field): field is DataGridCellDetail => field !== null);
|
||||
@@ -159,6 +163,7 @@ export function buildDataGridRowDetail(options: BuildDataGridRowDetailOptions):
|
||||
displayValue: options.displayValue,
|
||||
isEditable: options.isEditableColumn?.(columnIndex) ?? false,
|
||||
includeBinaryImagePreview: false,
|
||||
isValuePreviewTruncated: options.isValuePreviewTruncated?.(columnIndex),
|
||||
}),
|
||||
)
|
||||
.filter((field): field is DataGridCellDetail => field !== null);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export const TABLE_DATA_RESULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export function largeValueCellKey(rowIndex: number, columnIndex: number): string {
|
||||
return `${rowIndex}:${columnIndex}`;
|
||||
}
|
||||
|
||||
export function largeValueCellMap(result: Pick<QueryResult, "large_value_cells">): Map<string, NonNullable<QueryResult["large_value_cells"]>[number]> {
|
||||
return new Map((result.large_value_cells ?? []).map((cell) => [largeValueCellKey(cell.row_index, cell.column_index), cell]));
|
||||
}
|
||||
|
||||
export function appendLargeValueCells(previous: QueryResult["large_value_cells"], segment: QueryResult["large_value_cells"], rowOffset: number, appendedRowCount: number): QueryResult["large_value_cells"] {
|
||||
const appended = (segment ?? []).filter((cell) => cell.row_index < appendedRowCount).map((cell) => ({ ...cell, row_index: cell.row_index + rowOffset }));
|
||||
const combined = [...(previous ?? []), ...appended];
|
||||
return combined.length > 0 ? combined : undefined;
|
||||
}
|
||||
|
||||
export function remapLargeValueCells(cells: QueryResult["large_value_cells"], rowIndexes: number[]): QueryResult["large_value_cells"] {
|
||||
if (!cells?.length) return undefined;
|
||||
const targetBySource = new Map(rowIndexes.map((sourceIndex, targetIndex) => [sourceIndex, targetIndex]));
|
||||
const remapped = cells
|
||||
.map((cell) => {
|
||||
const rowIndex = targetBySource.get(cell.row_index);
|
||||
return rowIndex === undefined ? undefined : { ...cell, row_index: rowIndex };
|
||||
})
|
||||
.filter((cell): cell is NonNullable<typeof cell> => !!cell);
|
||||
return remapped.length > 0 ? remapped : undefined;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export interface TabResultSnapshot {
|
||||
* still return to the original result order.
|
||||
*/
|
||||
resultLocalSortOriginalRows?: QueryResult["rows"];
|
||||
resultLocalSortOriginalLargeValueCells?: QueryResult["large_value_cells"];
|
||||
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
|
||||
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
|
||||
resultRuns?: QueryTab["resultRuns"];
|
||||
@@ -48,6 +49,7 @@ interface ColumnarQueryResult {
|
||||
columns: string[];
|
||||
spatial_columns?: QueryResult["spatial_columns"];
|
||||
spatial_values?: QueryResult["spatial_values"];
|
||||
large_value_cells?: QueryResult["large_value_cells"];
|
||||
execution_error?: true;
|
||||
statement_index?: number;
|
||||
column_types?: string[];
|
||||
@@ -328,6 +330,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
|
||||
spatial_values: result.spatial_values?.map((row) => [...row]),
|
||||
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
|
||||
rows: result.rows.map((row) => [...row]),
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
|
||||
@@ -353,6 +356,7 @@ function stripResultRunSessionIds(resultRuns: QueryTab["resultRuns"]): QueryTab[
|
||||
result: stripSessionIds(run.result),
|
||||
results: stripResultSessionIds(run.results),
|
||||
resultLocalSortOriginalRows: run.resultLocalSortOriginalRows?.map((row) => [...row]),
|
||||
resultLocalSortOriginalLargeValueCells: run.resultLocalSortOriginalLargeValueCells?.map((cell) => ({ ...cell })),
|
||||
resultLocalSortOriginalMongoDocuments: run.resultLocalSortOriginalMongoDocuments ? clonePlain(run.resultLocalSortOriginalMongoDocuments) : undefined,
|
||||
resultLocalSortOriginalMongoCopyDocuments: run.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(run.resultLocalSortOriginalMongoCopyDocuments) : undefined,
|
||||
resultSessionId: undefined,
|
||||
@@ -369,6 +373,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
|
||||
spatial_values: result.spatial_values?.map((row) => [...row]),
|
||||
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
|
||||
columnValues,
|
||||
rowCount: result.rows.length,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
@@ -394,6 +399,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
spatial_columns: result.spatial_columns?.map((entry) => ({ column_index: entry.column_index, srid: entry.srid })),
|
||||
spatial_values: result.spatial_values?.map((row) => [...row]),
|
||||
large_value_cells: result.large_value_cells?.map((cell) => ({ ...cell })),
|
||||
rows,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
|
||||
|
||||
@@ -166,6 +166,58 @@ describe("queryStore table data refresh", () => {
|
||||
expect(store.tabs.find((tab) => tab.id === secondTabId)?.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the configured MySQL page size when results contain large-value previews", async () => {
|
||||
mocks.getConnectionConfig.mockReturnValue({
|
||||
id: "mysql-1",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
database: "app",
|
||||
query_timeout_secs: 30,
|
||||
});
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("mysql-1", "app", "users", "data");
|
||||
store.setTableMeta(tabId, {
|
||||
tableName: "users",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "payload", data_type: "longtext", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
primaryKeys: ["id"],
|
||||
});
|
||||
const tab = store.tabs.find((candidate) => candidate.id === tabId)!;
|
||||
tab.resultPageLimit = 100;
|
||||
tab.resultPageOffset = 0;
|
||||
mocks.executeMulti.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["id", "payload"],
|
||||
rows: Array.from({ length: 100 }, (_, index) => [String(index + 1), "preview..."]),
|
||||
large_value_cells: Array.from({ length: 100 }, (_, index) => ({ row_index: index, column_index: 1, original_bytes: 1_000_000 })),
|
||||
affected_rows: 100,
|
||||
execution_time_ms: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(store.refreshDataTab(tabId)).resolves.toBe(true);
|
||||
|
||||
expect(tab.result?.rows).toHaveLength(100);
|
||||
expect(tab.resultPageLimit).toBe(100);
|
||||
expect(mocks.executeMulti).toHaveBeenCalledWith(
|
||||
"mysql-1",
|
||||
"app",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
maxRows: 100,
|
||||
fetchSize: 100,
|
||||
maxResultBytes: 32 * 1024 * 1024,
|
||||
resultKeyColumns: ["id"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a MySQL table refresh unqualified in the selected database context", async () => {
|
||||
mocks.getConnectionConfig.mockReturnValue({
|
||||
id: "mysql-1",
|
||||
@@ -216,6 +268,7 @@ describe("queryStore table data refresh", () => {
|
||||
tab.resultSortMode = "database";
|
||||
tab.resultSortedSql = 'SELECT * FROM public.users ORDER BY "old_name" ASC';
|
||||
tab.resultLocalSortOriginalRows = [[1, "alpha"]];
|
||||
tab.resultLocalSortOriginalLargeValueCells = [{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }];
|
||||
tab.resultLocalSortOriginalMongoDocuments = [{ id: 1, old_name: "alpha" }];
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = [{ id: 1, old_name: "alpha" }];
|
||||
tab.resultPageLimit = 25;
|
||||
@@ -238,6 +291,7 @@ describe("queryStore table data refresh", () => {
|
||||
expect(tab.resultSortMode).toBeUndefined();
|
||||
expect(tab.resultSortedSql).toBeUndefined();
|
||||
expect(tab.resultLocalSortOriginalRows).toBeUndefined();
|
||||
expect(tab.resultLocalSortOriginalLargeValueCells).toBeUndefined();
|
||||
expect(tab.resultLocalSortOriginalMongoDocuments).toBeUndefined();
|
||||
expect(tab.resultLocalSortOriginalMongoCopyDocuments).toBeUndefined();
|
||||
expect(tab.orderByInput).toBeUndefined();
|
||||
|
||||
@@ -46,6 +46,7 @@ import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connect
|
||||
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
|
||||
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
|
||||
import { appendLargeValueCells, remapLargeValueCells, TABLE_DATA_RESULT_MAX_BYTES } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { agentProtocolQueryResultMaxRows, capQueryResultTotal, effectiveQueryResultMaxRows, limitQueryPagination, queryResultLimitReached } from "@/lib/dataGrid/queryResultRowLimit";
|
||||
@@ -150,6 +151,7 @@ function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): S
|
||||
|
||||
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
|
||||
markRaw(result.rows);
|
||||
if (result.large_value_cells) markRaw(result.large_value_cells);
|
||||
if (result.mongo_documents) markRaw(result.mongo_documents);
|
||||
if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents);
|
||||
return result;
|
||||
@@ -201,6 +203,7 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe
|
||||
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
|
||||
spatial_columns: spatial_columns.length > 0 ? spatial_columns : undefined,
|
||||
spatial_values: appendParallelValues(previous.spatial_values, segment.spatial_values),
|
||||
large_value_cells: appendLargeValueCells(previous.large_value_cells, segment.large_value_cells, previous.rows.length, appendedRowCount),
|
||||
mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents),
|
||||
mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents),
|
||||
execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0),
|
||||
@@ -213,6 +216,7 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns
|
||||
if (run.result) markQueryResultRowsRaw(run.result);
|
||||
if (run.results) markQueryResultsRowsRaw(run.results);
|
||||
if (run.resultLocalSortOriginalRows) markRaw(run.resultLocalSortOriginalRows);
|
||||
if (run.resultLocalSortOriginalLargeValueCells) markRaw(run.resultLocalSortOriginalLargeValueCells);
|
||||
if (run.resultLocalSortOriginalMongoDocuments) markRaw(run.resultLocalSortOriginalMongoDocuments);
|
||||
if (run.resultLocalSortOriginalMongoCopyDocuments) markRaw(run.resultLocalSortOriginalMongoCopyDocuments);
|
||||
}
|
||||
@@ -927,6 +931,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.batchSqlExecution = undefined;
|
||||
tab.resultEditorFingerprint = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
@@ -954,6 +959,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultSortDirection = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
tab.orderByInput = undefined;
|
||||
@@ -978,6 +984,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
run.result = undefined;
|
||||
run.results = undefined;
|
||||
run.resultLocalSortOriginalRows = undefined;
|
||||
run.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
run.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
run.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
run.resultSessionId = undefined;
|
||||
@@ -1007,6 +1014,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultSortDirection = run.resultSortDirection;
|
||||
tab.resultSortMode = run.resultSortMode;
|
||||
tab.resultLocalSortOriginalRows = run.resultLocalSortOriginalRows;
|
||||
tab.resultLocalSortOriginalLargeValueCells = run.resultLocalSortOriginalLargeValueCells;
|
||||
tab.resultLocalSortOriginalMongoDocuments = run.resultLocalSortOriginalMongoDocuments;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = run.resultLocalSortOriginalMongoCopyDocuments;
|
||||
tab.orderByInput = run.orderByInput;
|
||||
@@ -1243,6 +1251,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
resultSortDirection: tab.resultSortDirection,
|
||||
resultSortMode: tab.resultSortMode,
|
||||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
|
||||
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
|
||||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
|
||||
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
|
||||
orderByInput: tab.orderByInput,
|
||||
@@ -1339,6 +1348,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
resultSortDirection: tab.resultSortDirection,
|
||||
resultSortMode: tab.resultSortMode,
|
||||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
|
||||
resultLocalSortOriginalLargeValueCells: tab.resultLocalSortOriginalLargeValueCells,
|
||||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
|
||||
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
|
||||
orderByInput: tab.orderByInput,
|
||||
@@ -1392,6 +1402,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
|
||||
if (!tab.resultLocalSortOriginalRows) {
|
||||
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
|
||||
tab.resultLocalSortOriginalLargeValueCells = tab.result.large_value_cells?.map((cell) => ({ ...cell }));
|
||||
tab.resultLocalSortOriginalMongoDocuments = tab.result.mongo_documents?.slice();
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = tab.result.mongo_copy_documents?.slice();
|
||||
}
|
||||
@@ -1404,7 +1415,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const mongo_documents = originalMongoDocuments ? rowIndexes.map((index) => originalMongoDocuments[index]) : undefined;
|
||||
const originalMongoCopyDocuments = tab.resultLocalSortOriginalMongoCopyDocuments;
|
||||
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
|
||||
assignDisplayedResult(tab, { ...tab.result, rows, mongo_documents, mongo_copy_documents });
|
||||
const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes);
|
||||
assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents });
|
||||
|
||||
tab.resultSortColumn = direction ? column : undefined;
|
||||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||||
@@ -1413,6 +1425,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultSortedSql = undefined;
|
||||
if (!direction) {
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
}
|
||||
@@ -2544,6 +2557,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
resultSortDirection: undefined,
|
||||
resultSortMode: undefined,
|
||||
resultLocalSortOriginalRows: undefined,
|
||||
resultLocalSortOriginalLargeValueCells: undefined,
|
||||
resultLocalSortOriginalMongoDocuments: undefined,
|
||||
resultLocalSortOriginalMongoCopyDocuments: undefined,
|
||||
orderByInput: undefined,
|
||||
@@ -3124,6 +3138,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultSortMode = undefined;
|
||||
tab.resultSortedSql = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
}
|
||||
@@ -3808,6 +3823,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const previousDisplayedSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
|
||||
tab.lastExecutedSql = sql;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
if (captureResultRun && tab.result && !tab.activeResultRunId) {
|
||||
@@ -4465,6 +4481,12 @@ export const useQueryStore = defineStore("query", () => {
|
||||
: { maxRows: pageLimit, fetchSize: pageLimit }
|
||||
: { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }),
|
||||
...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}),
|
||||
...(tab.mode === "data" && effectiveDbType === "mysql"
|
||||
? {
|
||||
maxResultBytes: TABLE_DATA_RESULT_MAX_BYTES,
|
||||
resultKeyColumns: tableMetaForDataTab(tab)?.primaryKeys ?? [],
|
||||
}
|
||||
: {}),
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
catalog: executionCatalog,
|
||||
continueOnError: settingsStore.editorSettings.continueOnErrorOnBatch,
|
||||
@@ -5177,6 +5199,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.activeResultIndex = index;
|
||||
tab.result = tab.results[index];
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
tab.resultSortColumn = undefined;
|
||||
@@ -5300,6 +5323,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
|
||||
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
|
||||
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
|
||||
tab.resultLocalSortOriginalLargeValueCells = snapshot.resultLocalSortOriginalLargeValueCells?.map((cell) => ({ ...cell }));
|
||||
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = snapshot.resultLocalSortOriginalMongoCopyDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoCopyDocuments) : undefined;
|
||||
// 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象,
|
||||
|
||||
@@ -713,6 +713,8 @@ export interface QueryResult {
|
||||
/** Whether a backend-reported result total is exact. */
|
||||
total_is_exact?: boolean;
|
||||
truncated?: boolean;
|
||||
/** Variable-length cells represented by bounded previews in `rows`. */
|
||||
large_value_cells?: Array<{ row_index: number; column_index: number; original_bytes: number }>;
|
||||
session_id?: string | null;
|
||||
has_more?: boolean;
|
||||
/** For Elasticsearch REST search results parsed into a _source table,
|
||||
@@ -778,6 +780,7 @@ export interface QueryResultRun {
|
||||
resultSortDirection?: "asc" | "desc";
|
||||
resultSortMode?: "database" | "local";
|
||||
resultLocalSortOriginalRows?: QueryResult["rows"];
|
||||
resultLocalSortOriginalLargeValueCells?: QueryResult["large_value_cells"];
|
||||
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
|
||||
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
|
||||
orderByInput?: string;
|
||||
@@ -1085,6 +1088,7 @@ export interface QueryTab {
|
||||
resultSortDirection?: "asc" | "desc";
|
||||
resultSortMode?: "database" | "local";
|
||||
resultLocalSortOriginalRows?: QueryResult["rows"];
|
||||
resultLocalSortOriginalLargeValueCells?: QueryResult["large_value_cells"];
|
||||
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
|
||||
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
|
||||
orderByInput?: string;
|
||||
|
||||
Reference in new issue
Block a user