fix: render binary values as hex (#465)

This commit is contained in:
vvrf 2026-05-26 22:46:00 +08:00 committed by GitHub
parent 660ed3002f
commit 3a595c8efb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 128 additions and 29 deletions

View File

@ -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);
}

View File

@ -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"));
}
}

View File

@ -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<u8>, 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::<Vec<u8>, _>(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::<String, _>(row, idx) {
@ -114,8 +144,7 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value
.or_else(|| row_get::<i64, _>(row, idx).map(|v| serde_json::Value::String(v.to_string())))
.or_else(|| row_get::<u64, _>(row, idx).map(|v| serde_json::Value::String(v.to_string())))
.or_else(|| {
row_get::<Vec<u8>, _>(row, idx)
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string()))
row_get::<Vec<u8>, _>(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::<Vec<u8>, _>(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::<bool, _>(row, idx).map(serde_json::Value::Bool))
.or_else(|| {
row_get::<Vec<u8>, _>(row, idx).map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string()))
row_get::<Vec<u8>, _>(row, idx).map(|bytes| mysql_bytes_to_json(bytes, column))
})
.unwrap_or(serde_json::Value::Null)
}

View File

@ -124,6 +124,13 @@ fn pg_array_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
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<u8>>(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<u8>>(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<u8>>(idx).map(|bytes| super::binary_value_to_json(&bytes))
})
.unwrap_or(serde_json::Value::Null)
}

View File

@ -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),
}
}

View File

@ -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)) = <Vec<u8> 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<Vec<DatabaseInfo>, String> {
let stream = client
.query(

View File

@ -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);
});

View File

@ -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);

View File

@ -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("""