fix(mysql): avoid garbled binary value previews

This commit is contained in:
t8y2 2026-05-27 12:56:53 +08:00
parent 556edaf193
commit 745f37fa6d
1 changed files with 136 additions and 25 deletions

View File

@ -1,6 +1,6 @@
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use futures::StreamExt;
use mysql_async::consts::{ColumnFlags, ColumnType};
use mysql_async::consts::ColumnType;
use mysql_async::prelude::*;
use rust_decimal::Decimal;
use std::borrow::Cow;
@ -84,26 +84,72 @@ 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 is_mysql_binary_charset(column: &mysql_async::Column) -> bool {
column.character_set() == 63
}
fn is_mysql_blob_column(column: &mysql_async::Column) -> bool {
is_mysql_binary_charset(column)
&& matches!(
column.column_type(),
ColumnType::MYSQL_TYPE_BLOB
| ColumnType::MYSQL_TYPE_LONG_BLOB
| ColumnType::MYSQL_TYPE_MEDIUM_BLOB
| ColumnType::MYSQL_TYPE_TINY_BLOB
)
}
fn is_mysql_binary_string_column(column: &mysql_async::Column) -> bool {
is_mysql_binary_charset(column)
&& matches!(
column.column_type(),
ColumnType::MYSQL_TYPE_STRING | ColumnType::MYSQL_TYPE_VAR_STRING | ColumnType::MYSQL_TYPE_VARCHAR
)
}
fn mysql_printable_binary_preview(bytes: &[u8]) -> Option<String> {
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 {
serde_json::Value::String(format!("({label}) {} bytes", bytes.len()))
}
fn mysql_bit_value_to_string(bytes: &[u8], column: &mysql_async::Column) -> String {
let bit_len = column.column_length();
if bit_len > 1 {
let total_bits = bytes.len() * 8;
let mut bits = String::with_capacity(total_bits);
for byte in bytes {
bits.push_str(&format!("{byte:08b}"));
}
let start = bits.len().saturating_sub(bit_len as usize);
return bits[start..].to_string();
}
let val = bytes.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64);
val.to_string()
}
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);
if is_mysql_blob_column(column) {
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));
}
serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
}
@ -120,9 +166,9 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value
return serde_json::Value::Null;
}
if is_binary_column(column) {
if is_mysql_binary_string_column(column) {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| super::binary_value_to_json(&bytes))
.map(|bytes| mysql_bytes_to_json(bytes, column))
.unwrap_or(serde_json::Value::Null);
}
@ -154,10 +200,7 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value
}
ColumnType::MYSQL_TYPE_BIT => {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| {
let val = bytes.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64);
serde_json::Value::String(val.to_string())
})
.map(|bytes| serde_json::Value::String(mysql_bit_value_to_string(&bytes, column)))
.unwrap_or(serde_json::Value::Null);
}
ColumnType::MYSQL_TYPE_BLOB
@ -166,7 +209,13 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value
| ColumnType::MYSQL_TYPE_TINY_BLOB
| ColumnType::MYSQL_TYPE_GEOMETRY => {
return row_get::<Vec<u8>, _>(row, idx)
.map(|bytes| mysql_bytes_to_json(bytes, column))
.map(|bytes| {
if matches!(column.column_type(), ColumnType::MYSQL_TYPE_GEOMETRY) {
mysql_blob_preview(&bytes, "GEOMETRY")
} else {
mysql_bytes_to_json(bytes, column)
}
})
.unwrap_or(serde_json::Value::Null);
}
ColumnType::MYSQL_TYPE_TIMESTAMP
@ -787,6 +836,7 @@ pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Res
#[cfg(test)]
mod tests {
use super::*;
use mysql_async::consts::ColumnFlags;
#[test]
fn mysql_with_queries_are_treated_as_result_sets() {
@ -859,6 +909,67 @@ mod tests {
assert!(is_mysql_lossless_integer_type("LARGEINT"));
}
fn mysql_test_column(
column_type: ColumnType,
character_set: u16,
flags: ColumnFlags,
column_length: u32,
) -> mysql_async::Column {
mysql_async::Column::new(column_type)
.with_character_set(character_set)
.with_flags(flags)
.with_column_length(column_length)
}
#[test]
fn mysql_binary_preview_keeps_binary_collation_varchar_as_text() {
let column = mysql_test_column(ColumnType::MYSQL_TYPE_VAR_STRING, 45, ColumnFlags::BINARY_FLAG, 64);
assert_eq!(mysql_bytes_to_json(b"SN-A0001".to_vec(), &column), serde_json::json!("SN-A0001"));
}
#[test]
fn mysql_binary_preview_renders_binary_and_varbinary_like_navicat_text_preview() {
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"));
}
#[test]
fn mysql_binary_preview_falls_back_to_hex_for_unprintable_bytes() {
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(vec![0x01, 0x02, 0x03, 0x04], &binary_column), serde_json::json!("0x01020304"));
assert_eq!(
mysql_bytes_to_json(vec![0xde, 0xad, 0xbe, 0xef], &varbinary_column),
serde_json::json!("0xdeadbeef")
);
}
#[test]
fn mysql_binary_preview_uses_charset_to_separate_blob_from_text() {
let text_column = mysql_test_column(ColumnType::MYSQL_TYPE_BLOB, 45, ColumnFlags::empty(), 65_535);
let blob_column = mysql_test_column(ColumnType::MYSQL_TYPE_BLOB, 63, ColumnFlags::BLOB_FLAG, 65_535);
assert_eq!(mysql_bytes_to_json(b"hello".to_vec(), &text_column), serde_json::json!("hello"));
assert_eq!(
mysql_bytes_to_json(vec![0x00, 0x01, 0xab, 0xff], &blob_column),
serde_json::json!("(BLOB) 4 bytes")
);
}
#[test]
fn mysql_bit_preview_uses_boolean_or_bit_string_text() {
let bit_one = mysql_test_column(ColumnType::MYSQL_TYPE_BIT, 63, ColumnFlags::UNSIGNED_FLAG, 1);
let bit_eight = mysql_test_column(ColumnType::MYSQL_TYPE_BIT, 63, ColumnFlags::UNSIGNED_FLAG, 8);
assert_eq!(mysql_bit_value_to_string(&[1], &bit_one), "1");
assert_eq!(mysql_bit_value_to_string(&[0b1010_1010], &bit_eight), "10101010");
}
#[test]
fn mysql_column_key_marks_primary_when_key_column_usage_is_unavailable() {
let primary_key_columns = HashSet::new();