From 3a595c8efb60b5cc5a9339b1f4e30f80808ab601 Mon Sep 17 00:00:00 2001 From: vvrf Date: Tue, 26 May 2026 22:46:00 +0800 Subject: [PATCH] fix: render binary values as hex (#465) --- apps/desktop/src/lib/sqlAnalysis.ts | 5 +- crates/dbx-core/src/db/mod.rs | 24 ++++++++++ crates/dbx-core/src/db/mysql.rs | 46 +++++++++++++++++-- crates/dbx-core/src/db/postgres.rs | 15 +++--- crates/dbx-core/src/db/sqlite.rs | 3 +- crates/dbx-core/src/db/sqlserver.rs | 12 +---- packages/app-tests/sqlAnalysis.test.ts | 7 +++ .../main/java/app/dbx/jdbc/DbxJdbcPlugin.java | 32 +++++++++++-- .../java/app/dbx/jdbc/DbxJdbcPluginTest.java | 13 ++++++ 9 files changed, 128 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/lib/sqlAnalysis.ts b/apps/desktop/src/lib/sqlAnalysis.ts index d27c784ce..34f983c85 100644 --- a/apps/desktop/src/lib/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sqlAnalysis.ts @@ -14,7 +14,10 @@ export const BINARY_TYPES = new Set([ ]); export function isBinaryType(dataType: string): boolean { - const lower = dataType.toLowerCase(); + const lower = dataType + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, "") + .trim(); return BINARY_TYPES.has(lower); } diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index cf1236aa3..f1001f82e 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -45,6 +45,20 @@ pub fn safe_u64_to_json(v: u64) -> serde_json::Value { } } +pub(crate) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +pub(crate) fn binary_value_to_json(bytes: &[u8]) -> serde_json::Value { + serde_json::Value::String(format!("0x{}", hex_encode(bytes))) +} + pub fn tcp_probe_timeout() -> Duration { Duration::from_secs(TCP_PROBE_TIMEOUT_SECS) } @@ -89,3 +103,13 @@ pub async fn probe_tcp_endpoint(label: &str, host: &str, port: u16) -> Result<() .map(|_| ()) .map_err(|e| format!("{label} TCP connection failed: {e}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_values_are_displayed_as_prefixed_hex() { + assert_eq!(binary_value_to_json(&[0x00, 0x01, 0xab, 0xff]), serde_json::json!("0x0001abff")); + } +} diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index abcd5cc49..39ca7ce1e 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -1,6 +1,6 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use futures::StreamExt; -use mysql_async::consts::ColumnType; +use mysql_async::consts::{ColumnFlags, ColumnType}; use mysql_async::prelude::*; use rust_decimal::Decimal; use std::borrow::Cow; @@ -84,6 +84,30 @@ fn is_lossless_integer_column(column: &mysql_async::Column) -> bool { matches!(column.column_type(), ColumnType::MYSQL_TYPE_LONGLONG | ColumnType::MYSQL_TYPE_NEWDECIMAL) } +fn is_binary_column(column: &mysql_async::Column) -> bool { + let binary_flag = column.flags().contains(ColumnFlags::BINARY_FLAG); + let binary_charset = column.character_set() == 63; + matches!(column.column_type(), ColumnType::MYSQL_TYPE_GEOMETRY) + || ((binary_flag || binary_charset) + && matches!( + column.column_type(), + ColumnType::MYSQL_TYPE_BLOB + | ColumnType::MYSQL_TYPE_LONG_BLOB + | ColumnType::MYSQL_TYPE_MEDIUM_BLOB + | ColumnType::MYSQL_TYPE_TINY_BLOB + | ColumnType::MYSQL_TYPE_STRING + | ColumnType::MYSQL_TYPE_VAR_STRING + | ColumnType::MYSQL_TYPE_VARCHAR + )) +} + +fn mysql_bytes_to_json(bytes: Vec, column: &mysql_async::Column) -> serde_json::Value { + if is_binary_column(column) { + return super::binary_value_to_json(&bytes); + } + serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string()) +} + fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value { let Some(column) = row.columns_ref().get(idx) else { return serde_json::Value::Null; @@ -96,6 +120,12 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value return serde_json::Value::Null; } + if is_binary_column(column) { + return row_get::, _>(row, idx) + .map(|bytes| super::binary_value_to_json(&bytes)) + .unwrap_or(serde_json::Value::Null); + } + match column.column_type() { ColumnType::MYSQL_TYPE_JSON => { if let Some(v) = row_get::(row, idx) { @@ -114,8 +144,7 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value .or_else(|| row_get::(row, idx).map(|v| serde_json::Value::String(v.to_string()))) .or_else(|| row_get::(row, idx).map(|v| serde_json::Value::String(v.to_string()))) .or_else(|| { - row_get::, _>(row, idx) - .map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string())) + row_get::, _>(row, idx).map(|bytes| mysql_bytes_to_json(bytes, column)) }) .unwrap_or(serde_json::Value::Null); } @@ -133,6 +162,15 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value }) .unwrap_or(serde_json::Value::Null); } + ColumnType::MYSQL_TYPE_BLOB + | ColumnType::MYSQL_TYPE_LONG_BLOB + | ColumnType::MYSQL_TYPE_MEDIUM_BLOB + | ColumnType::MYSQL_TYPE_TINY_BLOB + | ColumnType::MYSQL_TYPE_GEOMETRY => { + return row_get::, _>(row, idx) + .map(|bytes| mysql_bytes_to_json(bytes, column)) + .unwrap_or(serde_json::Value::Null); + } ColumnType::MYSQL_TYPE_TIMESTAMP | ColumnType::MYSQL_TYPE_TIMESTAMP2 | ColumnType::MYSQL_TYPE_DATETIME @@ -167,7 +205,7 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value }) .or_else(|| row_get::(row, idx).map(serde_json::Value::Bool)) .or_else(|| { - row_get::, _>(row, idx).map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string())) + row_get::, _>(row, idx).map(|bytes| mysql_bytes_to_json(bytes, column)) }) .unwrap_or(serde_json::Value::Null) } diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 0ee945a99..1805c832d 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -124,6 +124,13 @@ fn pg_array_to_json_value(row: &Row, idx: usize) -> Option { fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value { let upper = type_name.to_uppercase(); + if upper == "BYTEA" { + return row + .try_get::<_, Vec>(idx) + .map(|bytes| super::binary_value_to_json(&bytes)) + .unwrap_or(serde_json::Value::Null); + } + if upper == "JSON" || upper == "JSONB" { if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) { return serde_json::Value::String(v.to_string()); @@ -195,13 +202,7 @@ fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value .or_else(|_| row.try_get::<_, uuid::Uuid>(idx).map(|v| serde_json::Value::String(v.to_string()))) .or_else(|e| pg_temporal_to_json_value(row, idx).ok_or(e)) .or_else(|_| { - row.try_get::<_, Vec>(idx).map(|bytes| match std::str::from_utf8(&bytes) { - Ok(s) => serde_json::Value::String(s.to_string()), - Err(_) => { - let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); - serde_json::Value::String(hex) - } - }) + row.try_get::<_, Vec>(idx).map(|bytes| super::binary_value_to_json(&bytes)) }) .unwrap_or(serde_json::Value::Null) } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index f9ec70554..2f047aca1 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -1,4 +1,3 @@ -use base64::prelude::{Engine as _, BASE64_STANDARD}; use rusqlite::types::ValueRef; use rusqlite::{Connection, OpenFlags}; use std::path::Path; @@ -542,6 +541,6 @@ fn value_ref_to_json(value: ValueRef<'_>) -> serde_json::Value { serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null) } ValueRef::Text(v) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()), - ValueRef::Blob(v) => serde_json::Value::String(BASE64_STANDARD.encode(v)), + ValueRef::Blob(v) => super::binary_value_to_json(v), } } diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 29436562f..82f609609 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -241,21 +241,11 @@ fn sqlserver_cell_to_json(cell: &ColumnData<'static>) -> serde_json::Value { return serde_json::Value::String(v.to_string()); } if let Ok(Some(v)) = as tiberius::FromSqlOwned>::from_sql_owned(cell.clone()) { - return serde_json::Value::String(format!("0x{}", hex_encode(&v))); + return super::binary_value_to_json(&v); } serde_json::Value::Null } -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} - pub async fn list_databases(client: &mut SqlServerClient) -> Result, String> { let stream = client .query( diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index 77a71f86c..2e929b39f 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -5,6 +5,7 @@ import { allPrimaryKeysPresent, analyzeEditableQuery, analyzeEditableQueryEditability, + isBinaryType, queryEditabilityMessageKey, } from "../../apps/desktop/src/lib/sqlAnalysis.ts"; @@ -99,3 +100,9 @@ test("accepts aliased primary key source columns for row identity", () => { true, ); }); + +test("recognizes binary type declarations with lengths", () => { + assert.equal(isBinaryType("binary(16)"), true); + assert.equal(isBinaryType("VARBINARY(255)"), true); + assert.equal(isBinaryType("varchar(255)"), false); +}); diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java index 05bcf67c8..cc25677f5 100644 --- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -26,9 +26,9 @@ import java.sql.SQLFeatureNotSupportedException; import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.sql.Types; import java.time.temporal.TemporalAccessor; import java.util.ArrayList; -import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Properties; @@ -236,7 +236,7 @@ public final class DbxJdbcPlugin { } ArrayNode row = MAPPER.createArrayNode(); for (int i = 1; i <= columnCount; i++) { - row.add(MAPPER.valueToTree(readValue(rs, i))); + row.add(MAPPER.valueToTree(readValue(rs, meta, i))); } rows.add(row); } @@ -681,13 +681,17 @@ public final class DbxJdbcPlugin { return keys; } - private static Object readValue(ResultSet rs, int index) throws SQLException { + private static Object readValue(ResultSet rs, ResultSetMetaData meta, int index) throws SQLException { Object value = rs.getObject(index); if (value == null) { return null; } if (value instanceof byte[] bytes) { - return Base64.getEncoder().encodeToString(bytes); + return binaryToHex(bytes); + } + if (isBinaryColumn(meta, index)) { + byte[] bytes = rs.getBytes(index); + return bytes == null ? null : binaryToHex(bytes); } if (value instanceof Date || value instanceof Time || value instanceof Timestamp || value instanceof TemporalAccessor) { return value.toString(); @@ -701,6 +705,26 @@ public final class DbxJdbcPlugin { return value.toString(); } + private static boolean isBinaryColumn(ResultSetMetaData meta, int index) throws SQLException { + return switch (meta.getColumnType(index)) { + case Types.BINARY, + Types.VARBINARY, + Types.LONGVARBINARY, + Types.BLOB -> true; + default -> false; + }; + } + + private static String binaryToHex(byte[] bytes) { + StringBuilder out = new StringBuilder(2 + bytes.length * 2); + out.append("0x"); + for (byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0x0f, 16)); + out.append(Character.forDigit(b & 0x0f, 16)); + } + return out.toString(); + } + private static void putNullable(ObjectNode node, String field, String value) { if (value == null) { node.putNull(field); diff --git a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java index 46fbf0c46..5056f9389 100644 --- a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java +++ b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java @@ -60,6 +60,19 @@ final class DbxJdbcPluginTest { assertEquals(1, response.path("result").path("rows").path(0).path(0).asInt()); } + @Test + void executeQueryFormatsBinaryColumnsAsHex() throws Exception { + JsonNode response = request("executeQuery", """ + { + "connection": %s, + "sql": "SELECT X'0001ABFF' AS payload" + } + """.formatted(CONNECTION)); + + assertFalse(response.has("error"), response.toString()); + assertEquals("0x0001abff", response.path("result").path("rows").path(0).path(0).asText()); + } + @Test void driverQuirksDetectYashanJdbcUrl() throws Exception { JsonNode yashan = MAPPER.readTree("""