fix(mysql): copy varbinary text values as raw strings
This commit is contained in:
1 parent
cd008b2d56
commit
14ad5ef9bf
8 files changed
+299
-35
No files matched your search
@@ -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<number[]>([]);
|
||||
|
||||
@@ -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<hex> 还原为原始字符串", 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<hex>,交由后端 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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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 ---
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
normalizeValues: boolean;
|
||||
presentBinaryText: boolean;
|
||||
rawRows: unknown[][];
|
||||
}
|
||||
|
||||
interface UseDataGridExtractorOptions {
|
||||
columns: ComputedRef<string[]>;
|
||||
displayItems: ComputedRef<ExtractorRowItem[]>;
|
||||
@@ -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<DataGridExtractRequest, { rowIds: number[]; sourceColumnIndexes: number[]; columnTypes: Array<string | undefined>; normalizeValues: boolean }>();
|
||||
const requestSources = new WeakMap<DataGridExtractRequest, ExtractorRequestSource>();
|
||||
|
||||
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<hex>` 还原为原始字符串。
|
||||
// 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<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 (!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;
|
||||
});
|
||||
|
||||
@@ -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<hex>`;前端只有在严格 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<hex>`;不要把普通的偶数长度
|
||||
// 文本(例如 "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 连接开启。
|
||||
|
||||
@@ -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<string, string> | 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<string, CellValue> = {};
|
||||
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<TRow> {
|
||||
|
||||
Reference in new issue
Block a user