fix(mysql): improve large-value table previews

This commit is contained in:
zipg authored and GitHub committed 2026-08-15 12:58:28 +08:00
1 parent 2d4b6f85fc
commit 1aec3ae000
10 files changed
+291 -49

No files matched your search

+64 -13
View File
@@ -4097,6 +4097,15 @@ function isLargeValuePreview(item: RowItem | undefined, columnIndex: number): bo
return largeValueCellsByKey.value.has(largeValueCellKey(item.sourceIndex, columnIndex));
}
function largeValueOriginalBytes(item: Pick<RowItem, "sourceIndex" | "isNew" | "isDraft" | "isDirtyCol"> | undefined, columnIndex: number): number | undefined {
if (resolvedDatabaseType.value !== "mysql" || !item || item.isNew || item.isDraft || item.sourceIndex === undefined || item.isDirtyCol[columnIndex]) return undefined;
return largeValueCellsByKey.value.get(largeValueCellKey(item.sourceIndex, columnIndex))?.original_bytes;
}
function formatGridItemCell(item: RowItem, columnIndex: number): string {
return formatCellCached(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex));
}
function normalizedLargeValueIdentityPart(value: CellValue): string {
if (value === null) return "null";
return `${typeof value}:${JSON.stringify(value)}`;
@@ -5385,7 +5394,7 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
return `${columnIndex ?? -1}\u0000${typeof value}\u0000${String(value)}`;
}
function formatCell(value: CellValue, columnIndex?: number): string {
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number): string {
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex);
const displayColumnInfo = columnInfo ?? (columnIndex === undefined ? undefined : resultColumnInfoForGridColumn(columnIndex));
@@ -5397,14 +5406,14 @@ function formatCell(value: CellValue, columnIndex?: number): string {
columnInfo: displayColumnInfo,
});
if (arrayDisplay !== undefined) return arrayDisplay;
const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnIndex === undefined ? undefined : allColumnTypes.value[columnIndex]);
const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnIndex === undefined ? undefined : allColumnTypes.value[columnIndex], originalBytes);
if (binaryDisplay !== null) return binaryDisplay;
const s = applyColumnFormatter(value, formatter);
return limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined);
}
function formatCellCached(value: CellValue, columnIndex?: number): string {
if (value !== null && typeof (value as unknown) === "object") {
function formatCellCached(value: CellValue, columnIndex?: number, originalBytes?: number): string {
if (originalBytes === undefined && value !== null && typeof (value as unknown) === "object") {
const objectValue = value as unknown as object;
const cacheColumn = columnIndex ?? -1;
const columnCache = objectCellFormatCache.get(objectValue);
@@ -5424,13 +5433,13 @@ function formatCellCached(value: CellValue, columnIndex?: number): string {
// 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);
return formatCell(value, columnIndex, originalBytes);
}
const key = primitiveCellFormatKey(value, columnIndex);
const key = `${primitiveCellFormatKey(value, columnIndex)}\u0000${originalBytes ?? ""}`;
const cached = primitiveCellFormatCache.get(key);
if (cached !== undefined) return cached;
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex));
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex, originalBytes));
}
function rowNumberPageOffset(): number {
@@ -6207,7 +6216,7 @@ function drawCanvasGrid() {
editingCell: editingCell.value,
searchMatchKeys: searchMatchSet.value,
currentSearchMatch: currentSearchMatch.value,
formatCell: formatCellCached,
formatCell: (value, columnIndex, row) => formatCellCached(value, columnIndex, largeValueOriginalBytes(row, columnIndex)),
draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"),
isRowActive,
rowCellsUseSelectionVisual,
@@ -6394,6 +6403,45 @@ async function cancelActiveExport() {
await exportCancelHandler.value?.();
}
const userFacingSql = ref("");
let userFacingSqlGeneration = 0;
async function syncUserFacingSql() {
const generation = ++userFacingSqlGeneration;
const executionSql = props.sql?.trim() ?? "";
if (props.context !== "table-data" || !executionSql.includes("__DBX_LARGE_VALUE_BYTES_") || !props.tableMeta?.tableName) {
userFacingSql.value = executionSql;
return;
}
try {
const config = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined;
const sql = await buildTableSelectSql({
databaseType: resolvedDatabaseType.value,
driverProfile: config?.driver_profile,
identifierQuote: props.connectionId ? connectionStore.connectionIdentifierQuote?.(props.connectionId) : undefined,
catalog: props.tableMeta.catalog,
database: props.tableMeta.database,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
tableType: props.tableMeta.tableType,
whereInput: currentWhereInput(),
orderBy: currentOrderBy(),
limit: props.pageLimit ?? pageSize.value,
offset: props.pageOffset ?? Math.max(0, currentPage.value - 1) * pageSize.value,
});
if (generation === userFacingSqlGeneration) userFacingSql.value = sql;
} catch {
if (generation === userFacingSqlGeneration) userFacingSql.value = executionSql;
}
}
watch(
() => [props.sql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, currentWhereInput(), currentOrderBy()],
() => void syncUserFacingSql(),
{ immediate: true },
);
// --- Export composable ---
const {
copyText,
@@ -6422,7 +6470,6 @@ const {
exportCurrentPageSql,
exportTxt,
exportCurrentPageTxt,
copySql,
} = useDataGridExport({
columns: visibleColumns,
displayItems: visibleDisplayItems,
@@ -8665,7 +8712,11 @@ function onRowContext(rowId: number, rowIndex: number) {
}
}
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
const sqlOneLiner = computed(() => userFacingSql.value.replace(/\s+/g, " ").trim());
async function copyUserFacingSql() {
if (userFacingSql.value) await copyText(userFacingSql.value);
}
type TableInfoTabItem = {
id: TableInfoTab;
@@ -11195,7 +11246,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
</template>
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx), flatteningMultiLineEnabled) }}</template>
<template v-else>{{ firstLineCellDisplayValue(formatGridItemCell(item, col.actualColIdx), flatteningMultiLineEnabled) }}</template>
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<LightDropdownMenu
v-if="canQuickDownloadCellValue(item.displayIndex, col.actualColIdx)"
@@ -11699,12 +11750,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<Tooltip v-if="sqlOneLiner">
<TooltipTrigger as-child>
<span class="min-w-0 max-w-full justify-self-center truncate opacity-60 cursor-pointer hover:opacity-100" @click="copySql">
<span class="min-w-0 max-w-full justify-self-center truncate opacity-60 cursor-pointer hover:opacity-100" @click="copyUserFacingSql">
{{ sqlOneLiner }}
</span>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-md">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ props.sql }}</pre>
<pre class="text-xs font-mono whitespace-pre-wrap">{{ userFacingSql }}</pre>
</TooltipContent>
</Tooltip>
<span v-else class="min-w-0" />
@@ -66,6 +66,31 @@ describe("data grid large-value metadata", () => {
}
});
it("uses the active MySQL page budget to select bounded preview columns", () => {
const columns = [column("id", "int", true), column("image_mime", "varchar(64)"), column("image_data", "longblob"), column("image_url", "varchar(512)"), column("image_note", "varchar(255)")];
expect(tableDataLargeValuePreviewOptions("mysql", columns, ["id"], 10_000)).toEqual({
columnTypes: columns.map((item) => item.data_type),
largeValuePreviewSize: 139,
});
expect(tableDataLargeValuePreviewOptions("mysql", columns, ["id"], 100)).toEqual({
columnTypes: columns.map((item) => item.data_type),
largeValuePreviewSize: TABLE_DATA_CELL_PREVIEW_SIZE,
});
expect(
tableDataLargeValuePreviewOptions(
"mysql",
columns.filter((item) => item.name !== "image_data"),
["id"],
100,
),
).toEqual({});
expect(tableDataLargeValuePreviewOptions("mysql", [column("id", "int", true), column("payload", "varchar(8000)")], ["id"], 10_000)).toEqual({
columnTypes: ["int", "varchar(8000)"],
largeValuePreviewSize: 419,
});
});
it("offsets appended segment rows and discards metadata beyond the append cap", () => {
expect(
appendLargeValueCells(
@@ -108,6 +108,7 @@ export function retainBinaryCellDownloadMenuForHover(openCell: BinaryCellPositio
}
const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]*)$/;
const TRUNCATED_HEX_VALUE_RE = /^(?:0[xX]|\\x)[0-9a-fA-F\s]+\.\.\.$/;
const BARE_HEX_RE = /^[0-9a-fA-F\s]+$/;
const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\s)+$/;
const BINARY_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
@@ -192,7 +193,11 @@ export function canDownloadBinaryCellValue(value: unknown, columnType?: string):
return !!parseBinaryCellBytes(value, columnType);
}
export function binaryCellDisplayText(value: unknown, columnType?: string): string | null {
export function binaryCellDisplayText(value: unknown, columnType?: string, originalBytes?: number): string | null {
if (typeof value === "string" && isBinaryCellColumnType(columnType) && TRUNCATED_HEX_VALUE_RE.test(value.trim())) {
const size = originalBytes === undefined ? "..." : formatBinaryCellByteSize(originalBytes);
return `${binaryCellDisplayLabel(columnType)} [${size}]`;
}
const bytes = parseBinaryCellBytes(value, columnType);
if (!bytes || !isBinaryCellColumnType(columnType)) return null;
if (BINARY_STRING_TYPE_RE.test((columnType ?? "").trim())) {
@@ -26,6 +26,7 @@ export interface CanvasDataGridRow {
isDeleted: boolean;
isDirtyCol: boolean[];
status: RowStatus;
sourceIndex?: number;
}
export interface CanvasHoverCell {
@@ -77,7 +78,7 @@ export interface DrawCanvasDataGridOptions {
editingCell: CanvasEditingCell | null;
searchMatchKeys: ReadonlySet<number>;
currentSearchMatch: CanvasSearchMatch | null;
formatCell: (value: CellValue, columnIndex: number) => string;
formatCell: (value: CellValue, columnIndex: number, row: CanvasDataGridRow) => string;
columnIsBoolean?: (columnIndex: number) => boolean;
draftCellPlaceholder?: string;
isRowActive: (rowIndex: number) => boolean;
@@ -576,7 +577,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.stroke();
}
} else {
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx, item);
const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText, flatteningMultiLineEnabled);
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isBooleanNullCell ? "left" : isRightAlign ? "right" : "left");
const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, scaleX) : textAnchorX;
@@ -7,6 +7,46 @@ export const TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES = 24 * 1024 * 1024;
export const TABLE_DATA_TEXT_SERIALIZED_BYTES_PER_CHARACTER = 6;
const TABLE_DATA_LARGE_VALUE_MARKER_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
function normalizedDataTypeBase(dataType: string): string {
return dataType.trim().split(/[([]/, 1)[0]?.trim().toLocaleLowerCase() ?? "";
}
function declaredDataTypeLength(dataType: string): number | undefined {
const match = dataType.match(/\(\s*(\d+)/);
if (!match?.[1]) return undefined;
const length = Number.parseInt(match[1], 10);
return Number.isSafeInteger(length) ? length : undefined;
}
function mysqlColumnNeedsLargeValuePreview(column: ColumnInfo, previewSize: number): boolean {
const base = normalizedDataTypeBase(column.data_type);
if (matchesMysqlUnboundedLargeValueType(base)) return true;
if (base !== "varchar" && base !== "varbinary") return false;
return (declaredDataTypeLength(column.data_type) ?? 0) > previewSize;
}
function previewSizeForColumnCount(pageSize: number | undefined, previewColumnCount: number): number {
const budgetedSize = Math.floor(TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES / Math.max(1, pageSize ?? 1) / previewColumnCount / TABLE_DATA_TEXT_SERIALIZED_BYTES_PER_CHARACTER);
return Math.max(TABLE_DATA_CELL_PREVIEW_MIN_SIZE, Math.min(TABLE_DATA_CELL_PREVIEW_SIZE, budgetedSize));
}
function mysqlPreviewBudget(columns: readonly ColumnInfo[], keyColumns: ReadonlySet<string>, pageSize: number | undefined): { previewColumnCount: number; previewSize: number } | null {
let previewColumnCount = 1;
let previewSize = previewSizeForColumnCount(pageSize, previewColumnCount);
for (;;) {
const nextCount = columns.filter((column) => !keyColumns.has(column.name.toLocaleLowerCase()) && mysqlColumnNeedsLargeValuePreview(column, previewSize)).length;
if (nextCount === 0) return null;
const nextSize = previewSizeForColumnCount(pageSize, nextCount);
if (nextCount === previewColumnCount && nextSize === previewSize) return { previewColumnCount: nextCount, previewSize: nextSize };
previewColumnCount = nextCount;
previewSize = nextSize;
}
}
function matchesMysqlUnboundedLargeValueType(base: string): boolean {
return base === "blob" || base === "mediumblob" || base === "longblob" || base === "text" || base === "mediumtext" || base === "longtext" || base === "json";
}
export function canUseTableDataLargeValuePreview(databaseType: DatabaseType | undefined, columns: readonly ColumnInfo[], primaryKeys: readonly string[]): boolean {
return (databaseType === "mysql" || databaseType === "postgres") && columns.length > 0 && primaryKeys.length > 0 && !columns.some((column) => column.name.toLocaleUpperCase().startsWith(TABLE_DATA_LARGE_VALUE_MARKER_PREFIX));
}
@@ -14,12 +54,12 @@ export function canUseTableDataLargeValuePreview(databaseType: DatabaseType | un
export function tableDataLargeValuePreviewOptions(databaseType: DatabaseType | undefined, columns: readonly ColumnInfo[], primaryKeys: readonly string[], pageSize?: number): { columnTypes: string[]; largeValuePreviewSize: number } | Record<string, never> {
if (!canUseTableDataLargeValuePreview(databaseType, columns, primaryKeys)) return {};
const keyColumns = new Set(primaryKeys.map((column) => column.toLocaleLowerCase()));
const previewColumnCount = columns.filter((column) => !keyColumns.has(column.name.toLocaleLowerCase())).length;
const mysqlBudget = databaseType === "mysql" ? mysqlPreviewBudget(columns, keyColumns, pageSize) : null;
const previewColumnCount = databaseType === "mysql" ? (mysqlBudget?.previewColumnCount ?? 0) : columns.filter((column) => !keyColumns.has(column.name.toLocaleLowerCase())).length;
if (previewColumnCount === 0) return {};
const budgetedSize = Math.floor(TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES / Math.max(1, pageSize ?? 1) / previewColumnCount / TABLE_DATA_TEXT_SERIALIZED_BYTES_PER_CHARACTER);
return {
columnTypes: columns.map((column) => column.data_type),
largeValuePreviewSize: Math.max(TABLE_DATA_CELL_PREVIEW_MIN_SIZE, Math.min(TABLE_DATA_CELL_PREVIEW_SIZE, budgetedSize)),
largeValuePreviewSize: mysqlBudget?.previewSize ?? previewSizeForColumnCount(pageSize, previewColumnCount),
};
}