diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 26f441eda6..56134c3694 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5036,7 +5036,6 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string function formatCell(value: CellValue, columnIndex?: number): string { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; - const columnName = columnIndex === undefined ? undefined : props.result.columns[columnIndex]; const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex); const displayColumnInfo = columnInfo ?? (columnIndex === undefined ? undefined : resultColumnInfoForGridColumn(columnIndex)); const arrayDisplay = formatter @@ -5047,8 +5046,8 @@ function formatCell(value: CellValue, columnIndex?: number): string { columnInfo: displayColumnInfo, }); if (arrayDisplay !== undefined) return arrayDisplay; - const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnInfo?.data_type ?? (columnName ? columnTypeMap.value.get(columnName) : undefined)); - if (binaryDisplay) return binaryDisplay; + const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnIndex === undefined ? undefined : allColumnTypes.value[columnIndex]); + if (binaryDisplay !== null) return binaryDisplay; const s = applyColumnFormatter(value, formatter); return limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined); } diff --git a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts index a2e1e6c57c..a83c421863 100644 --- a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts +++ b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts @@ -107,10 +107,12 @@ export function retainBinaryCellDownloadMenuForHover(openCell: BinaryCellPositio return openCell?.rowIndex === hoveredCell.rowIndex && openCell.col === hoveredCell.col ? openCell : null; } -const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/; +const 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; +const FIXED_BINARY_TYPE_RE = /^binary(?:\b|\()/i; +const BINARY_STRING_TYPE_RE = /^(?:binary|varbinary)(?:\b|\()/i; const MYSQL_FILE_IMPORT_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|binary|varbinary)(?:\b|\()/i; function copyBytesForBlob(bytes: Uint8Array): Uint8Array { @@ -127,7 +129,8 @@ export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null { function bytesFromHex(value: string): Uint8Array | null { const hex = value.replace(/\s+/g, ""); - if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null; + if (hex.length === 0) return new Uint8Array(); + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { @@ -192,9 +195,35 @@ export function canDownloadBinaryCellValue(value: unknown, columnType?: string): export function binaryCellDisplayText(value: unknown, columnType?: string): string | null { const bytes = parseBinaryCellBytes(value, columnType); if (!bytes || !isBinaryCellColumnType(columnType)) return null; + if (BINARY_STRING_TYPE_RE.test((columnType ?? "").trim())) { + const previewBytes = FIXED_BINARY_TYPE_RE.test((columnType ?? "").trim()) ? trimTrailingNullBytes(bytes) : bytes; + const text = printableUtf8Text(previewBytes); + if (text !== null) return text; + } return `${binaryCellDisplayLabel(columnType)} [${formatBinaryCellByteSize(bytes.length)}]`; } +function trimTrailingNullBytes(bytes: Uint8Array): Uint8Array { + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + return bytes.subarray(0, end); +} + +function printableUtf8Text(bytes: Uint8Array): string | null { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } + for (const char of text) { + const codePoint = char.codePointAt(0) ?? 0; + const allowedWhitespace = codePoint === 9 || codePoint === 10 || codePoint === 13; + if (!allowedWhitespace && (codePoint <= 31 || (codePoint >= 127 && codePoint <= 159))) return null; + } + return text; +} + function binaryCellDisplayLabel(columnType?: string): string { const base = (columnType ?? "") .trim() diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 03a15ae7f3..9866837afb 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -283,21 +283,6 @@ fn is_mysql_binary_string_column(column: &mysql_async::Column) -> bool { ) } -fn mysql_printable_binary_preview(bytes: &[u8]) -> Option { - let trimmed = bytes.strip_suffix(&[0]).map_or(bytes, |mut value| { - while let Some(rest) = value.strip_suffix(&[0]) { - value = rest; - } - value - }); - if trimmed.is_empty() { - return Some(String::new()); - } - - let text = std::str::from_utf8(trimmed).ok()?; - text.chars().all(|ch| !ch.is_control() || matches!(ch, '\t' | '\n' | '\r')).then(|| text.to_string()) -} - fn mysql_blob_preview(bytes: &[u8], label: &str) -> serde_json::Value { if label == "BLOB" { return super::binary_value_to_json(bytes); @@ -326,9 +311,7 @@ fn mysql_bytes_to_json(bytes: Vec, column: &mysql_async::Column) -> serde_js return mysql_blob_preview(&bytes, "BLOB"); } if is_mysql_binary_string_column(column) { - return mysql_printable_binary_preview(&bytes) - .map(serde_json::Value::String) - .unwrap_or_else(|| super::binary_value_to_json(&bytes)); + return super::binary_value_to_json(&bytes); } serde_json::Value::String(bytes_to_string_lossy(bytes)) } @@ -6483,16 +6466,23 @@ mod tests { } #[test] - fn mysql_binary_preview_renders_binary_and_varbinary_like_navicat_text_preview() { + fn mysql_binary_values_preserve_all_bytes_as_hex() { let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_STRING, 63, ColumnFlags::BINARY_FLAG, 8); let varbinary_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 63, ColumnFlags::BINARY_FLAG, 8); - assert_eq!(mysql_bytes_to_json(b"150010\0\0".to_vec(), &binary_column), serde_json::json!("150010")); - assert_eq!(mysql_bytes_to_json(b"150010".to_vec(), &varbinary_column), serde_json::json!("150010")); + assert_eq!( + mysql_bytes_to_json(b"150010\0\0".to_vec(), &binary_column), + serde_json::json!("0x3135303031300000") + ); + assert_eq!(mysql_bytes_to_json(b"150010".to_vec(), &varbinary_column), serde_json::json!("0x313530303130")); + assert_eq!( + mysql_bytes_to_json(b"SN-A0001".to_vec(), &varbinary_column), + serde_json::json!("0x534e2d4130303031") + ); } #[test] - fn mysql_binary_preview_falls_back_to_hex_for_unprintable_bytes() { + fn mysql_binary_values_preserve_unprintable_bytes_as_hex() { let binary_column = mysql_test_column(ColumnType::MYSQL_TYPE_STRING, 63, ColumnFlags::BINARY_FLAG, 8); let varbinary_column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 63, ColumnFlags::BINARY_FLAG, 8); diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts index 838785395d..7183676564 100644 --- a/packages/app-tests/binaryCellDownload.test.ts +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -19,6 +19,7 @@ import { test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => { assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]); assert.deepEqual(Array.from(parseBinaryCellHexValue("\\x00 ff") ?? []), [0, 255]); + assert.deepEqual(Array.from(parseBinaryCellHexValue("0x") ?? []), []); }); test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => { @@ -29,6 +30,8 @@ test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => { test("parseBinaryCellBytes accepts common driver binary shapes", () => { assert.deepEqual(Array.from(parseBinaryCellBytes("89504e47", "BLOB") ?? []), [137, 80, 78, 71]); + assert.deepEqual(Array.from(parseBinaryCellBytes("0x534e2d4130303031", "VARBINARY(8)") ?? []), [83, 78, 45, 65, 48, 48, 48, 49]); + assert.deepEqual(Array.from(parseBinaryCellBytes("0x3135303031300000", "BINARY(8)") ?? []), [49, 53, 48, 48, 49, 48, 0, 0]); assert.deepEqual(Array.from(parseBinaryCellBytes("\\x89\\x50\\x4e\\x47") ?? []), [137, 80, 78, 71]); assert.deepEqual(Array.from(parseBinaryCellBytes([0, 1, 171, 255]) ?? []), [0, 1, 171, 255]); assert.deepEqual(Array.from(parseBinaryCellBytes({ type: "Buffer", data: [222, 173, 190, 239] }) ?? []), [222, 173, 190, 239]); @@ -63,10 +66,17 @@ test("canDownloadBinaryCellValue allows displayed binary hex strings", () => { assert.equal(canDownloadBinaryCellValue("89504e47"), false); }); -test("binaryCellDisplayText summarizes binary values for grid display", () => { +test("binaryCellDisplayText previews printable binary strings without changing their raw bytes", () => { + assert.equal(binaryCellDisplayText("0x534e2d4130303031", "VARBINARY(8)"), "SN-A0001"); + assert.equal(binaryCellDisplayText("0x3135303031300000", "BINARY(8)"), "150010"); + assert.equal(binaryCellDisplayText("0x0000", "BINARY(2)"), ""); + assert.equal(binaryCellDisplayText("0x68690a", "VARBINARY(3)"), "hi\n"); + assert.equal(binaryCellDisplayText("0x680069", "VARBINARY(3)"), "VARBINARY [3 bytes]"); + assert.equal(binaryCellDisplayText("0xdeadbeef", "VARBINARY(4)"), "VARBINARY [4 bytes]"); assert.equal(binaryCellDisplayText("0x89504e47", "BLOB"), "BLOB [4 bytes]"); assert.equal(binaryCellDisplayText(`0x${"00".repeat(2048)}`, "VARBINARY(2048)"), "VARBINARY [2.0 KB]"); assert.equal(binaryCellDisplayText("0x89504e47"), null); + assert.equal(binaryCellDisplayText("0x", "VARBINARY(0)"), ""); }); test("binaryCellDownloadPayload builds raw and decoded payloads", () => { @@ -79,6 +89,19 @@ test("binaryCellDownloadPayload builds raw and decoded payloads", () => { assert.equal(text.mimeType, "text/plain;charset=utf-8"); assert.equal(text.extension, "txt"); assert.equal(text.data, "Hi"); + + const paddedBinary = binaryCellDownloadPayload("0x3135303031300000", "binary", "BINARY(8)"); + assert.deepEqual(Array.from(paddedBinary.data as Uint8Array), [49, 53, 48, 48, 49, 48, 0, 0]); + + const emptyBinary = binaryCellDownloadPayload("0x", "binary", "VARBINARY(0)"); + assert.deepEqual(Array.from(emptyBinary.data as Uint8Array), []); +}); + +test("DataGrid binary preview prefers ResultSet column types when table metadata is unavailable", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const formatter = source.match(/function formatCell\([^]*?\n\}/)?.[0] ?? ""; + + assert.match(formatter, /binaryCellDisplayText\(value, columnIndex === undefined \? undefined : allColumnTypes\.value\[columnIndex\]\)/); }); test("binaryCellDownloadPayload decodes GBK text bytes", () => {