fix: render binary values as hex (#465)
This commit is contained in:
parent
660ed3002f
commit
3a595c8efb
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("""
|
||||
|
|
|
|||
Loading…
Reference in New Issue