fix(mysql): improve large-value table previews
This commit is contained in:
10 files changed
+291
-49
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user