diff --git a/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue index 6bbda3d148..6bf52de5e3 100644 --- a/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue +++ b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue @@ -30,7 +30,7 @@ const props = defineProps<{ downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise; canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean; importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise; - /** BLOB 文本预览与编辑写回一致,仅在 MySQL 连接开启。 */ + /** BLOB 文本预览与编辑写回一致,在 MySQL / Oracle(含 OceanBase-Oracle)连接开启。 */ databaseType?: DatabaseType; }>(); diff --git a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue index c93a29e72a..be6679e6d9 100644 --- a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue +++ b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue @@ -39,7 +39,7 @@ const props = defineProps<{ importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise; openImagePreview: (src: string, title: string) => void; canCopySqlCondition: () => boolean; - /** BLOB 文本预览与编辑写回一致,仅在 MySQL 连接开启。 */ + /** BLOB 文本预览与编辑写回一致,在 MySQL / Oracle(含 OceanBase-Oracle)连接开启。 */ databaseType?: DatabaseType; }>(); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 50106cf9b3..9a153c4d80 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -2091,6 +2091,57 @@ describe("cell detail surfaces", () => { expect(copyText).toHaveBeenCalledWith("#2005805"); }); + it("presents printable Oracle BLOB bytes as text and copies the presented value", async () => { + const copyText = vi.fn(); + const blobDetail = detail({ + type: "BLOB", + value: "0x48656c6c6f", + rawValue: "0x48656c6c6f", + rawValuePreview: "0x48656c6c6f", + displayValue: "Hello", + displayValuePreview: "Hello", + formattedJson: "", + }); + const dialog = mountComponent(DataGridCellDetailDialog, { + open: true, + detail: blobDetail, + typeColorClass: () => "", + openImagePreview: vi.fn(), + copyText, + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + databaseType: "oracle", + }); + const panel = mountComponent(DataGridCellDetailPanel, { + detail: blobDetail, + panelIsBottom: true, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + databaseType: "oracle", + }); + + expect(hostText(dialog.root)).toContain("Hello"); + expect(hostText(panel.root)).toContain("Hello"); + dispatch( + findOne(dialog.root, (node) => node.props.title === "grid.copyValue"), + "click", + ); + expect(copyText).toHaveBeenCalledWith("Hello"); + }); + it("keeps non-MySQL BLOB detail previews in hex", () => { const panel = mountComponent(DataGridCellDetailPanel, { detail: detail({ type: "BLOB", value: "0x2332303035383035", rawValue: "0x2332303035383035", rawValuePreview: "0x2332303035383035", displayValue: "BLOB [8 bytes]", displayValuePreview: "BLOB [8 bytes]", formattedJson: "" }), diff --git a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts index cfa160bbba..34d7a77e3b 100644 --- a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts +++ b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts @@ -306,14 +306,18 @@ export function binaryCellClipboardText(value: unknown, columnType?: string, dat return printableGbkText(binaryPreviewBytes(bytes, columnType)); } -// MySQL BLOB 的文本预览必须与编辑写回路径(coerceMysqlBlobTextValue,仅 mysql)走同一闸门: +export function isBlobTextPreviewDatabase(databaseType?: DatabaseType): boolean { + return databaseType === "mysql" || databaseType === "oracle" || databaseType === "oceanbase-oracle"; +} + +// MySQL 与 Oracle(含 OceanBase-Oracle)BLOB 的文本预览必须与编辑写回路径(coerceBlobTextValue)走同一闸门: // SQLite/DuckDB/H2/Firebird 等库同样暴露 `blob` 列,若一律按文本预览会出现 -// “显示是文本、编辑器却是十六进制”的不一致,故 blob 文本预览仅在 mysql 连接开启。 +// “显示是文本、编辑器却是十六进制”的不一致,故 blob 文本预览仅在支持的连接开启。 // binary/varbinary 的文本预览早于该特性存在(如 TDengine BINARY 文本),保持全库通用。 function isBinaryCellTextPreviewColumn(columnType: string | undefined, databaseType: DatabaseType | undefined): boolean { if (isOpaqueAggregateStateColumnType(columnType)) return false; if (BINARY_STRING_TYPE_RE.test((columnType ?? "").trim())) return true; - return isBlobCellColumnType(columnType) && databaseType === "mysql"; + return isBlobCellColumnType(columnType) && isBlobTextPreviewDatabase(databaseType); } // 显示/复制路径的解码链:先严格 UTF-8,仅 MySQL 连接的 binary/varbinary 再回退严格 diff --git a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts index e1133923c1..da776dbd71 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts @@ -1,6 +1,6 @@ import type { GridCellValue } from "@/lib/dataGrid/dataGridSql"; import type { DatabaseType, ColumnInfo } from "@/types/database"; -import { binaryCellBytesToHexValue, binaryCellUtf8Text, isBlobCellColumnType } from "@/lib/dataGrid/binaryCellDownload"; +import { binaryCellBytesToHexValue, binaryCellUtf8Text, isBlobCellColumnType, isBlobTextPreviewDatabase } from "@/lib/dataGrid/binaryCellDownload"; import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; import { isBooleanColumnType } from "@/lib/dataGrid/dataGridBooleanColumn"; @@ -26,7 +26,7 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions) const { value, oldValue } = options; if (value === "" && options.emptyStringAsNull) return null; if (value === "" && oldValue === null && !options.preserveEmptyString) return null; - const blobValue = coerceMysqlBlobTextValue(options); + const blobValue = coerceBlobTextValue(options); if (blobValue !== undefined) return blobValue; const postgresArrayValue = coercePostgresArrayValue(options); if (postgresArrayValue !== undefined) return postgresArrayValue; @@ -82,7 +82,7 @@ function parseBooleanInput(value: string, allowNumericAliases: boolean): boolean export function dataGridCellEditorText(options: { value: GridCellValue | undefined; databaseType: DatabaseType | undefined; columnInfo: Pick | undefined }): string { const value = options.value ?? null; if (value === null) return ""; - if (options.databaseType === "mysql" && isBlobCellColumnType(options.columnInfo?.data_type)) { + if (isBlobTextPreviewDatabase(options.databaseType) && isBlobCellColumnType(options.columnInfo?.data_type)) { const text = binaryCellUtf8Text(value, options.columnInfo?.data_type, options.databaseType); if (text !== null) return text; } @@ -92,8 +92,8 @@ export function dataGridCellEditorText(options: { value: GridCellValue | undefin return typeof value === "object" ? JSON.stringify(value) : String(value); } -function coerceMysqlBlobTextValue(options: CoerceDataGridCellValueOptions): GridCellValue | undefined { - if (options.databaseType !== "mysql" || !isBlobCellColumnType(options.columnInfo?.data_type)) return undefined; +function coerceBlobTextValue(options: CoerceDataGridCellValueOptions): GridCellValue | undefined { + if (!isBlobTextPreviewDatabase(options.databaseType) || !isBlobCellColumnType(options.columnInfo?.data_type)) return undefined; const originalText = binaryCellUtf8Text(options.oldValue, options.columnInfo?.data_type, options.databaseType); if (originalText === null) return undefined; if (options.value === originalText) return options.oldValue; diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts index c0143ec736..5ee03e96bf 100644 --- a/packages/app-tests/binaryCellDownload.test.ts +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -13,6 +13,7 @@ import { isBinaryCellColumnType, binaryCellUtf8Text, isBlobCellColumnType, + isBlobTextPreviewDatabase, MAX_BINARY_CELL_IMPORT_BYTES, parseBinaryCellBytes, parseBinaryCellHexValue, @@ -246,7 +247,14 @@ test("binaryCellClipboardText decodes textual MySQL varbinary and preserves arbi // 回归:SQLite/DuckDB 等库同样有 `blob` 列,文本预览必须与编辑写回路径一样仅对 mysql 开启, // 否则出现“单元格/详情显示文本、编辑器却是十六进制”的不一致。 -test("BLOB text preview stays limited to MySQL connections", () => { +test("BLOB text preview stays limited to MySQL and Oracle family connections", () => { + assert.equal(isBlobTextPreviewDatabase("mysql"), true); + assert.equal(isBlobTextPreviewDatabase("oracle"), true); + assert.equal(isBlobTextPreviewDatabase("oceanbase-oracle"), true); + assert.equal(isBlobTextPreviewDatabase("sqlite"), false); + assert.equal(isBlobTextPreviewDatabase("duckdb"), false); + assert.equal(isBlobTextPreviewDatabase(undefined), false); + assert.equal(binaryCellUtf8Text("0x2332303035383035", "LONGBLOB", "sqlite"), null); assert.equal(binaryCellUtf8Text("0x2332303035383035", "BLOB", "duckdb"), null); assert.equal(binaryCellUtf8Text("0x2332303035383035", "LONGBLOB", undefined), null); @@ -254,6 +262,14 @@ test("BLOB text preview stays limited to MySQL connections", () => { assert.equal(binaryCellDisplayText("0x2332303035383035", "BLOB", undefined, "duckdb"), "BLOB [8 bytes]"); assert.equal(binaryCellDisplayText("0x2332303035383035", "LONGBLOB", undefined), "BLOB [8 bytes]"); assert.equal(binaryCellDisplayText("0x2332303035383035", "LONGBLOB", undefined, "mysql"), "#2005805"); + + // Oracle 及 OceanBase-Oracle 的 BLOB 文本预览 + assert.equal(binaryCellUtf8Text("0x48656c6c6f", "BLOB", "oracle"), "Hello"); + assert.equal(binaryCellUtf8Text("0x48656c6c6f", "BLOB", "oceanbase-oracle"), "Hello"); + assert.equal(binaryCellDisplayText("0x48656c6c6f", "BLOB", undefined, "oracle"), "Hello"); + assert.equal(binaryCellDisplayText("0x48656c6c6f", "BLOB", undefined, "oceanbase-oracle"), "Hello"); + assert.equal(binaryCellDisplayText("0x89504e47", "BLOB", undefined, "oracle"), "BLOB [4 bytes]"); + // binary/varbinary 的文本预览是既有行为(如 TDengine BINARY 文本),不受 mysql 闸门影响。 assert.equal(binaryCellDisplayText("0x534e2d4130303031", "VARBINARY(8)", undefined, "sqlite"), "SN-A0001"); }); diff --git a/packages/app-tests/dataGridCellCoercion.test.ts b/packages/app-tests/dataGridCellCoercion.test.ts index 606839dc63..212691837f 100644 --- a/packages/app-tests/dataGridCellCoercion.test.ts +++ b/packages/app-tests/dataGridCellCoercion.test.ts @@ -210,3 +210,16 @@ test("MySQL binary BLOB editor keeps non-text bytes in hex", () => { assert.equal(dataGridCellEditorText({ value: oldValue, databaseType: "mysql", columnInfo }), oldValue); assert.equal(coerceDataGridCellValue({ value: oldValue, oldValue, databaseType: "mysql", columnInfo }), oldValue); }); + +test("Oracle and OceanBase-Oracle text BLOB editor decodes UTF-8 and writes edited bytes as hex", () => { + const oldValue = "0x2332303035383035"; + const columnInfo = { data_type: "blob" }; + + for (const databaseType of ["oracle", "oceanbase-oracle"] as const) { + assert.equal(dataGridCellEditorText({ value: oldValue, databaseType, columnInfo }), "#2005805"); + assert.strictEqual(coerceDataGridCellValue({ value: "#2005805", oldValue, databaseType, columnInfo }), oldValue); + assert.equal(coerceDataGridCellValue({ value: "新表达式", oldValue, databaseType, columnInfo }), "0xe696b0e8a1a8e8bebee5bc8f"); + assert.equal(coerceDataGridCellValue({ value: "", oldValue, databaseType, columnInfo }), "0x"); + } +}); +