diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7b2132be9f..e9f8e2c04c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -144,6 +144,7 @@ import { binaryCellDisplayText, binaryCellDownloadFileName, binaryCellDownloadPayload, + binaryCellClipboardText, canImportBinaryCellFile, canDownloadBinaryCellValue, downloadBinaryCellPayload, @@ -9736,13 +9737,20 @@ async function onGridKeydown(event: KeyboardEvent) { } } +function detailClipboardText(detail: DataGridCellDetail): string { + if (detail.value === null) return ""; + const binaryText = binaryCellClipboardText(detail.value, detail.type, resolvedDatabaseType.value); + if (binaryText !== null) return binaryText; + if (isBlobCellColumnType(detail.type)) return binaryCellUtf8Text(detail.value, detail.type, resolvedDatabaseType.value) ?? displayCellValue(detail.value); + return displayCellValue(detail.value); +} + 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 ? "" : isBlobCellColumnType(detail.type) ? (binaryCellUtf8Text(detail.value, detail.type, resolvedDatabaseType.value) ?? displayCellValue(detail.value)) : displayCellValue(detail.value); - copyText(text); + copyText(detailClipboardText(detail)); } async function copyDetailFormattedJson() { @@ -9929,7 +9937,7 @@ async function copyRowDetailJson() { try { const detail = await resolvedRowDetailForCopy(); if (!detail) return; - copyText(dataGridRowDetailJson(detail)); + copyText(dataGridRowDetailJson(detail, undefined, resolvedDatabaseType.value)); } catch (error) { reportLargeValueLoadError(error); } @@ -9939,7 +9947,7 @@ async function copyRowDetailTsv() { try { const detail = await resolvedRowDetailForCopy(); if (!detail) return; - copyText(dataGridRowDetailTsv(detail)); + copyText(dataGridRowDetailTsv(detail, resolvedDatabaseType.value)); } catch (error) { reportLargeValueLoadError(error); } @@ -9948,14 +9956,14 @@ async function copyRowDetailTsv() { 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)); + if (resolved) copyText(detailClipboardText(resolved)); } async function copyColumnDetailJson() { try { const detail = await resolvedColumnDetailForCopy(); if (!detail) return; - copyText(dataGridColumnDetailJson(detail)); + copyText(dataGridColumnDetailJson(detail, resolvedDatabaseType.value)); } catch (error) { reportLargeValueLoadError(error); } @@ -9965,7 +9973,7 @@ async function copyColumnDetailTsv() { try { const detail = await resolvedColumnDetailForCopy(); if (!detail) return; - copyText(dataGridColumnDetailTsv(detail)); + copyText(dataGridColumnDetailTsv(detail, resolvedDatabaseType.value)); } catch (error) { reportLargeValueLoadError(error); } @@ -9980,7 +9988,7 @@ function copyColumnDetailColumnName() { 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)); + if (resolved) copyText(detailClipboardText(resolved)); } const transposeRecordWidths = ref([]); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 2f85ad9dc9..3c3eeaf2bc 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -1466,3 +1466,122 @@ describe("useDataGridExport prepared row statements", () => { expect(copyToClipboard).not.toHaveBeenCalled(); }); }); + +// issue #7471:文本型 MySQL VARBINARY 复制单元格/多选/整行时,外部剪贴板应呈现原始字符串; +// SQL 路径与 DBX 内部网格回粘仍保留 hex 以保证 byte-for-byte round-trip,非文本二进制也始终保持 hex。 +describe("useDataGridExport VARBINARY 文本复制 (#7471)", () => { + const varbinTable: DataGridTableMeta = { + tableName: "test_varbin", + primaryKeys: ["id"], + columns: [ + { name: "id", data_type: "int", is_nullable: false, is_primary_key: true }, + { name: "name", data_type: "varbinary(128)", is_nullable: false }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + clearDataGridClipboardCopy(); + }); + + it("复制文本型 VARBINARY 单元格时把 0x 还原为原始字符串", async () => { + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["0x616263"]] }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "abc", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 }); + const state = createExportState(varbinTable, ["id", "name"], matrix, [1, "0x616263"]); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "raw", rows: [["abc"]] })); + expect(copyToClipboard).toHaveBeenCalledWith("abc"); + // OS 剪贴板是文本,DBX 内部剪贴板仍保留原 hex,粘回 VARBINARY 时不会重编码或丢字节。 + expect(parseDataGridClipboard("abc")).toEqual([["0x616263"]]); + }); + + it("非文本 VARBINARY 保持 0x/hex,避免破坏任意 bytes", async () => { + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["0xdeadbeef"]] }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "0xdeadbeef", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 }); + const state = createExportState(varbinTable, ["id", "name"], matrix, [1, "0xdeadbeef"]); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "raw", rows: [["0xdeadbeef"]] })); + }); + + it("多选 TSV 复制同样对文本型 VARBINARY 解码", async () => { + const rows = [["0x616263"], ["0x31"]]; + const matrix: CellSelectionMatrix = { rowIndexes: [0, 1], columnIndexes: [1], columns: ["name"], rows }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "abc\n1", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 1 }); + const state = createExportState(varbinTable, ["id", "name"], matrix, undefined, undefined, [ + [1, "0x616263"], + [2, "0x31"], + ]); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "tsv", rows: [["abc"], ["1"]] })); + expect(parseDataGridClipboard("abc\n1")).toEqual([["0x616263"], ["0x31"]]); + }); + + it("JSON 复制格式同样呈现文本型 VARBINARY", async () => { + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["0x616263"]] }; + const text = JSON.stringify({ name: "abc" }, null, 2); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text, mimeType: "application/json", fileExtension: "json", rowCount: 1, columnCount: 1 }); + const state = createExportState(varbinTable, ["id", "name"], matrix, [1, "0x616263"]); + + await expect(state.copyWithExtractor("json")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "json", rows: [["abc"]] })); + expect(copyToClipboard).toHaveBeenCalledWith(text); + }); + + it("SQL 提取器保持 hex 字面量以保证 round-trip", async () => { + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["0x616263"]] }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "INSERT INTO `test_varbin` (`name`) VALUES (0x616263);", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + const state = createExportState(varbinTable, ["id", "name"], matrix, [1, "0x616263"]); + + await expect(state.copyWithExtractor("sql-inserts")).resolves.toBe(true); + + // SQL 路径不解码:请求里仍是 0x,交由后端 data_grid_sql 生成安全的 hex 字面量。 + expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "sql-inserts", rows: [["0x616263"]] })); + }); + + it("右键「复制单元格」对文本型 VARBINARY 直接写入原始字符串", async () => { + const state = createExportState(varbinTable, ["id", "name"], undefined, [1, "0x616263"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1); + + await state.copyCell(); + + expect(copyToClipboard).toHaveBeenCalledWith("abc"); + // 剪贴板走本地 copyCell 路径,不应呼叫后端提取器。 + expect(extractDataGridSelection).not.toHaveBeenCalled(); + }); + + it("右键「复制单元格」对非文本 VARBINARY 保持 0x/hex", async () => { + const state = createExportState(varbinTable, ["id", "name"], undefined, [1, "0xdeadbeef"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1); + + await state.copyCell(); + + expect(copyToClipboard).toHaveBeenCalledWith("0xdeadbeef"); + }); + + it("复制整行 JSON 时呈现文本型 VARBINARY", async () => { + const state = createExportState(varbinTable, ["id", "name"], undefined, [1, "0x616263"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1); + + await state.copyRow(); + + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(JSON.parse(copied)).toEqual({ id: 1, name: "abc" }); + }); + + it("复制全部时外部文本呈现 VARBINARY,内部网格副本保留 hex", async () => { + const state = createExportState(varbinTable, ["id", "name"], undefined, [1, "0x616263"]); + + await state.copyAll(); + + const text = "id\tname\n1\tabc"; + expect(copyToClipboard).toHaveBeenCalledWith(text); + expect(parseDataGridClipboard(text)).toEqual([ + ["id", "name"], + ["1", "0x616263"], + ]); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index ac9b55598b..a1dff83f3c 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -9,6 +9,7 @@ import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dat import { useToast } from "@/composables/useToast"; import { useExportTracker } from "@/composables/useExportTracker"; import { clipboardCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation"; import { copyToClipboard } from "@/lib/common/clipboard"; import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; @@ -492,6 +493,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + function binaryClipboardCellValue(value: CellValue, columnIndex: number): CellValue { + return binaryCellClipboardText(value, columnTypes.value?.[columnIndex], databaseType.value) ?? value; + } + function rowToJsonObject(item: RowItem): Record { if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) { const original = options.mongoDocuments?.value?.[item.sourceIndex]; @@ -500,12 +505,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const obj: Record = {}; columns.value.forEach((col, i) => { - const value = item.data[i]; + const value = binaryClipboardCellValue(item.data[i], i); if (typeof value === "string" && columnTypes.value?.[i]?.trim().toLowerCase() === "json") { try { obj[col] = JSON.parse(value); return; - } catch {} + } catch { + // Keep malformed JSON cells as their original text. + } } obj[col] = value; }); @@ -529,7 +536,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { 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(clipboardCellValue(val), { rows: [[val]] }); + // 外部剪贴板呈现文本型 MySQL VARBINARY(NULL 也按空串输出);内部网格副本仍保留原 hex,保证回粘无损。 + await copyText(clipboardCellValue(binaryClipboardCellValue(val, contextCell.value.col)), { rows: [[val]] }); } async function copyRow() { @@ -672,7 +680,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyAll() { const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data); - await copyText(formatTsv(columns.value, rows), { rows, header: columns.value }); + // formatTsv 引用处理解码后文本中可能出现的制表符/换行;内部副本仍用原始 rows(hex)保证回粘无损。 + const decodedRows = rows.map((row) => row.map((cell, index) => binaryClipboardCellValue(cell, index))); + await copyText(formatTsv(columns.value, decodedRows), { rows, header: columns.value }); } // --- Export functions --- diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 0e1159515e..4763985f93 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -19,6 +19,7 @@ import { type DataGridExtractWarningCode, } from "@/lib/dataGrid/dataGridCopyExtractor"; import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; +import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { formatError } from "@/lib/backend/errorUtils"; import type { DatabaseType } from "@/types/database"; @@ -28,6 +29,15 @@ interface ExtractorRowItem { isDraft?: boolean; } +interface ExtractorRequestSource { + rowIds: number[]; + sourceColumnIndexes: number[]; + columnTypes: Array; + normalizeValues: boolean; + presentBinaryText: boolean; + rawRows: unknown[][]; +} + interface UseDataGridExtractorOptions { columns: ComputedRef; displayItems: ComputedRef; @@ -60,7 +70,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; normalizeValues: boolean }>(); + const requestSources = new WeakMap(); function normalizeCellValue(value: unknown, columnType: string | undefined): unknown { if (typeof value !== "string" || columnType?.trim().toLowerCase() !== "json") return value; @@ -71,6 +81,16 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } } + // 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x` 还原为原始字符串。 + // SQL 必须继续持有 hex 才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。 + function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean): unknown { + if (presentBinaryText) { + const text = binaryCellClipboardText(value, columnType, options.databaseType.value); + if (text !== null) return text; + } + return normalizeValues ? normalizeCellValue(value, columnType) : value; + } + function selectionData(): SelectionData | null { if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) { const rows = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data); @@ -186,7 +206,11 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { })); const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined); - const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => (descriptor.category === "json" || descriptor.category === "sql" ? normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex)) : row[sourceIndex]))); + const columnTypes = requiredSourceIndexes.map((sourceIndex) => columnTypesBySource.get(sourceIndex)); + const normalizeValues = descriptor.category === "json" || descriptor.category === "sql"; + const presentBinaryText = descriptor.category !== "sql"; + const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex])); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText))); const tableMeta = descriptor.category === "sql" ? compactTableMeta( @@ -209,28 +233,46 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { requestSources.set(request, { rowIds: sourceRowIds, sourceColumnIndexes: requiredSourceIndexes, - columnTypes: requiredSourceIndexes.map((sourceIndex) => columnTypesBySource.get(sourceIndex)), - normalizeValues: descriptor.category === "json" || descriptor.category === "sql", + columnTypes, + normalizeValues, + presentBinaryText, + rawRows, }); return request; } async function resolveRequestSourceValues(request: DataGridExtractRequest, rowLimit?: number): Promise { 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 (!source) return rowLimit === undefined ? request : { ...request, rows: request.rows.slice(0, rowLimit) }; + + const limitedSource: ExtractorRequestSource = + rowLimit === undefined + ? source + : { + ...source, + rowIds: source.rowIds.slice(0, rowLimit), + rawRows: source.rawRows.slice(0, rowLimit), + }; + if (!options.resolveSourceValues) { + if (rowLimit === undefined) return request; + const limitedRequest = { ...request, rows: request.rows.slice(0, rowLimit) }; + requestSources.set(limitedRequest, limitedSource); + return limitedRequest; + } + + const overlays = await options.resolveSourceValues(limitedSource.rowIds, limitedSource.sourceColumnIndexes); + const rawRows = limitedSource.rawRows.map((row, rowIndex) => { + const values = overlays.get(limitedSource.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; + const sourceIndex = limitedSource.sourceColumnIndexes[compactIndex]; + return sourceIndex !== undefined && values.has(sourceIndex) ? values.get(sourceIndex) : value; }); }); - return { ...request, rows }; + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText))); + const resolvedRequest = { ...request, rows }; + requestSources.set(resolvedRequest, { ...limitedSource, rawRows }); + return resolvedRequest; } function canBuildSqlUpdateRequest(): boolean { @@ -337,7 +379,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const gridCopy = isGridTabularCopy ? (() => { const includeRowHeader = extractor !== "raw" && request.options.dsv.includeRowHeader; - const rows = request.rows.map((row, rowIndex) => { + const rawRows = requestSources.get(request)?.rawRows ?? request.rows; + const rows = rawRows.map((row, rowIndex) => { const selectedRow = request.selectedColumnIndexes.map((index) => row[index]); return includeRowHeader ? [rowIndex + 1, ...selectedRow] : selectedRow; }); diff --git a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts index 53a1c818f1..3e122a19bd 100644 --- a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts +++ b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts @@ -114,6 +114,7 @@ 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; const FIXED_BINARY_TYPE_RE = /^binary(?:\b|\()/i; const BINARY_STRING_TYPE_RE = /^(?:binary|varbinary)(?:\b|\()/i; +const VARBINARY_TYPE_RE = /^varbinary(?:\b|\()/i; const BLOB_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob)(?:\b|\()/i; const MYSQL_FILE_IMPORT_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|binary|varbinary)(?:\b|\()/i; @@ -220,6 +221,23 @@ export function binaryCellUtf8Text(value: unknown, columnType?: string, database return binaryCellUtf8TextBytes(bytes, columnType); } +// 复制到剪贴板时,把「文本型」MySQL VARBINARY 单元格还原成其原始字符串。 +// DBX 后端为保留任意 bytes,把该值统一序列化成 `0x`;前端只有在严格 UTF-8 解码、 +// 无控制字符且重新编码后与原 bytes 完全一致时,才把 payload(如 token)复制为文本。 +// 其余情况返回 null,让调用方沿用无损 hex。范围严格限定 MySQL VARBINARY,避免改变 +// MySQL BINARY/BLOB、SQL Server VARBINARY、PostgreSQL BYTEA、Oracle RAW 等现有语义。 +export function binaryCellClipboardText(value: unknown, columnType?: string, databaseType?: DatabaseType): string | null { + if (databaseType !== "mysql" || !VARBINARY_TYPE_RE.test((columnType ?? "").trim())) return null; + // MySQL QueryResult 的 binary canonical form 是 `0x`;不要把普通的偶数长度 + // 文本(例如 "abcd")猜测成 hex bytes。 + const bytes = typeof value === "string" ? parseBinaryCellHexValue(value) : null; + if (!bytes) return null; + const text = binaryCellUtf8TextBytes(bytes, columnType); + if (text === null) return null; + const encoded = new TextEncoder().encode(text); + return encoded.length === bytes.length && encoded.every((byte, index) => byte === bytes[index]) ? text : null; +} + // MySQL BLOB 的文本预览必须与编辑写回路径(coerceMysqlBlobTextValue,仅 mysql)走同一闸门: // SQLite/DuckDB/H2/Firebird 等库同样暴露 `blob` 列,若一律按文本预览会出现 // “显示是文本、编辑器却是十六进制”的不一致,故 blob 文本预览仅在 mysql 连接开启。 diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 8d25d14cce..c1c2fe4e70 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -1,4 +1,5 @@ import { cellImagePreviewUrl } from "@/lib/dataGrid/cellImageUrl"; +import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { clipboardCellValue, displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { formatJsonText } from "@/lib/dataGrid/cellDetailPresentation"; import type { DatabaseType } from "@/types/database"; @@ -186,11 +187,11 @@ function detailColumnType(typeByColumn: ReadonlyMap | undefined, return resultColumnTypes?.[columnIndex]?.trim() ?? ""; } -export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown): string { +export function dataGridRowDetailJson(detail: DataGridRowDetail, originalDocument?: unknown, databaseType?: DatabaseType): string { if (originalDocument !== undefined) return JSON.stringify(jsonDetailDisplayValue(originalDocument), null, 2); const row: Record = {}; detail.fields.forEach((field) => { - row[field.column] = field.value; + row[field.column] = binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value; }); return JSON.stringify(jsonDetailDisplayValue(row), null, 2); } @@ -214,23 +215,23 @@ export function jsonDetailDisplayValue(value: unknown): unknown { return value; } -export function dataGridRowDetailTsv(detail: DataGridRowDetail): string { - return detail.fields.map((field) => clipboardCellValue(field.value)).join("\t"); +export function dataGridRowDetailTsv(detail: DataGridRowDetail, databaseType?: DatabaseType): string { + return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\t"); } -export function dataGridColumnDetailJson(detail: DataGridColumnDetail): string { +export function dataGridColumnDetailJson(detail: DataGridColumnDetail, databaseType?: DatabaseType): string { return JSON.stringify( detail.fields.map((field) => ({ row: field.rowNumber, - value: field.value, + value: binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value, })), null, 2, ); } -export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string { - return detail.fields.map((field) => clipboardCellValue(field.value)).join("\n"); +export function dataGridColumnDetailTsv(detail: DataGridColumnDetail, databaseType?: DatabaseType): string { + return detail.fields.map((field) => clipboardCellValue(binaryCellClipboardText(field.value, field.type, databaseType) ?? field.value)).join("\n"); } export interface BuildDeleteRowConfirmDetailsOptions { diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts index 3156e184be..289902356b 100644 --- a/packages/app-tests/binaryCellDownload.test.ts +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -5,6 +5,7 @@ import { test } from "vitest"; import { BinaryCellImportTooLargeError, binaryCellDisplayText, + binaryCellClipboardText, binaryCellDownloadFileName, binaryCellDownloadPayload, canDownloadBinaryCellValue, @@ -107,6 +108,44 @@ test("binaryCellUtf8Text only returns strict printable text", () => { assert.equal(binaryCellUtf8Text("0x4869", "varchar", "mysql"), null); }); +// issue #7471:MySQL VARBINARY 的文本 payload 复制为原始字符串,任意二进制保持 0x/hex 无损。 +test("binaryCellClipboardText decodes textual MySQL varbinary and preserves arbitrary bytes", () => { + // Case 1: ASCII VARBINARY(issue 示例 abc → 0x616263)。 + assert.equal(binaryCellClipboardText("0x616263", "VARBINARY(128)", "mysql"), "abc"); + // Case 2: 数字字符串。 + assert.equal(binaryCellClipboardText("0x31", "VARBINARY(128)", "mysql"), "1"); + assert.equal(binaryCellClipboardText("0x6b384a39784c326d51347650", "VARBINARY(128)", "mysql"), "k8J9xL2mQ4vP"); + // Case 3: 较长 ASCII token 必须完整复制。 + assert.equal(binaryCellClipboardText("0x75736572393832335f746f6b656e5f586b38396d4e32714c307750347652", "VARBINARY(128)", "mysql"), "user9823_token_Xk89mN2qL0wP4vR"); + // Case 4: UTF-8 中文 lossless round-trip。 + assert.equal(binaryCellClipboardText("0xe4b8ade69687", "VARBINARY(128)", "mysql"), "中文"); + // emoji(多字节 UTF-8)。 + assert.equal(binaryCellClipboardText("0xf09f9880", "VARBINARY(64)", "mysql"), "😀"); + // 空字符串及允许的文本换行。 + assert.equal(binaryCellClipboardText("0x", "VARBINARY(0)", "mysql"), ""); + assert.equal(binaryCellClipboardText("0x68690a", "VARBINARY(3)", "mysql"), "hi\n"); + + // Case 5: arbitrary binary(非法 UTF-8 / 含 NUL / 含控制字符)保持 null → 复制端沿用 0x/hex,绝不产生 � 或丢字节。 + assert.equal(binaryCellClipboardText("0xdeadbeef", "VARBINARY(4)", "mysql"), null); + assert.equal(binaryCellClipboardText("0xfffe", "VARBINARY(2)", "mysql"), null); + assert.equal(binaryCellClipboardText("0x0061", "VARBINARY(2)", "mysql"), null); // 含 NUL + assert.equal(binaryCellClipboardText("0x0102", "VARBINARY(2)", "mysql"), null); // 控制字符 + assert.equal(binaryCellClipboardText("0xefbbbf616263", "VARBINARY(6)", "mysql"), null); // 解码会吞 BOM,无法 byte-for-byte 回编码 + + // Case 6: NULL 保持原行为(helper 只处理字符串形态的 hex 值)。 + assert.equal(binaryCellClipboardText(null, "VARBINARY(128)", "mysql"), null); + + // Case 7: 非目标类型 / 非 MySQL 一律不改写,回归保护。 + assert.equal(binaryCellClipboardText("0x616263", "BLOB", "mysql"), null); // MySQL BLOB 不跟随 + assert.equal(binaryCellClipboardText("0x616263", "LONGBLOB", "mysql"), null); + assert.equal(binaryCellClipboardText("0x616263", "BINARY(8)", "mysql"), null); // MySQL BINARY 不跟随 + assert.equal(binaryCellClipboardText("0x616263", "VARBINARY(128)", "sqlserver"), null); // SQL Server varbinary 不改 + assert.equal(binaryCellClipboardText("0x616263", "bytea", "postgres"), null); // Postgres bytea 不改 + assert.equal(binaryCellClipboardText("0x616263", "BINARY(16)", "tdengine"), null); // 非 MySQL BINARY 不改 + assert.equal(binaryCellClipboardText("0x616263", "varchar", "mysql"), null); // 非二进制列不改 + assert.equal(binaryCellClipboardText("abcd", "VARBINARY(4)", "mysql"), null); // 普通文本不猜测为裸 hex +}); + // 回归:SQLite/DuckDB 等库同样有 `blob` 列,文本预览必须与编辑写回路径一样仅对 mysql 开启, // 否则出现“单元格/详情显示文本、编辑器却是十六进制”的不一致。 test("BLOB text preview stays limited to MySQL connections", () => { diff --git a/packages/app-tests/dataGridDetail.test.ts b/packages/app-tests/dataGridDetail.test.ts index 1be5c46fb9..907b06c76d 100644 --- a/packages/app-tests/dataGridDetail.test.ts +++ b/packages/app-tests/dataGridDetail.test.ts @@ -225,6 +225,32 @@ test("dataGridRowDetailJson and dataGridRowDetailTsv format copy payloads", () = assert.equal(dataGridRowDetailTsv(detail), "1\tAda\t"); }); +test("data grid detail copy renders textual MySQL VARBINARY while preserving non-text bytes", () => { + const rowDetail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: ["0x616263", "0xdeadbeef"], + columns: ["name", "payload"], + columnIndexes: [0, 1], + resultColumnTypes: ["varbinary(128)", "varbinary(4)"], + displayValue: (value) => String(value), + }); + const columnDetail = buildDataGridColumnDetail({ + rows: [{ rowIndex: 0, rowId: 1, row: ["0x616263"] }], + columns: ["name"], + columnIndex: 0, + resultColumnTypes: ["varbinary(128)"], + displayValue: (value) => String(value), + }); + + assert.equal(dataGridRowDetailJson(rowDetail, undefined, "mysql"), '{\n "name": "abc",\n "payload": "0xdeadbeef"\n}'); + assert.equal(dataGridRowDetailTsv(rowDetail, "mysql"), "abc\t0xdeadbeef"); + assert.equal(dataGridColumnDetailJson(columnDetail!, "mysql"), '[\n {\n "row": 1,\n "value": "abc"\n }\n]'); + assert.equal(dataGridColumnDetailTsv(columnDetail!, "mysql"), "abc"); + // 非 MySQL binary 及非文本 VARBINARY 不应被误转成字符串或 replacement character。 + assert.equal(dataGridRowDetailJson(rowDetail), '{\n "name": "0x616263",\n "payload": "0xdeadbeef"\n}'); +}); + test("dataGridRowDetailJson uses the original MongoDB document for nested values", () => { const document = { _id: { $oid: "507f1f77bcf86cd799439011" },