fix: resolve PostgreSQL/MySQL/SQL Server NUMERIC/DECIMAL display issues

- Add rust_decimal feature to sqlx and tiberius for proper Decimal type handling
- Fix try_get_unchecked<String> causing NULL or garbled output for numeric types
- Add numeric_precision and numeric_scale to ColumnInfo for all database drivers
- Update frontend to display precision (e.g., numeric(20,6)) in column headers
- Fix typeColorClass to handle type names with precision suffix

Root cause: PostgreSQL binary protocol sends NUMERIC as PgNumeric binary format,
not UTF-8 text. try_get_unchecked<String> attempted to decode binary bytes as UTF-8,
causing decode failures (NULL) or garbage characters (乱码).
This commit is contained in:
kingcanfish 2026-05-02 10:14:01 +08:00
parent 76dfe9935d
commit d1075c0042
No known key found for this signature in database
GPG Key ID: 964BAF0CEE2B0770
13 changed files with 62 additions and 20 deletions

5
src-tauri/Cargo.lock generated
View File

@ -1563,6 +1563,7 @@ dependencies = [
"redis",
"reqwest 0.12.28",
"russh",
"rust_decimal",
"rustls",
"serde",
"serde_json",
@ -6778,6 +6779,7 @@ dependencies = [
"native-tls",
"once_cell",
"percent-encoding",
"rust_decimal",
"serde",
"serde_json",
"sha2 0.10.9",
@ -6860,6 +6862,7 @@ dependencies = [
"percent-encoding",
"rand 0.8.6",
"rsa 0.9.10",
"rust_decimal",
"serde",
"sha1 0.10.6",
"sha2 0.10.9",
@ -6899,6 +6902,7 @@ dependencies = [
"memchr",
"once_cell",
"rand 0.8.6",
"rust_decimal",
"serde",
"serde_json",
"sha2 0.10.9",
@ -7652,6 +7656,7 @@ dependencies = [
"once_cell",
"pin-project-lite",
"pretty-hex",
"rust_decimal",
"thiserror 1.0.69",
"tracing",
"uuid",

View File

@ -27,7 +27,8 @@ serde_json = "1.0"
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid", "rust_decimal"] }
rust_decimal = { version = "1", features = ["serde"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
@ -39,7 +40,7 @@ redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rus
rustls = { version = "0.23", features = ["aws-lc-rs"] }
portpicker = "0.1.1"
duckdb = { version = "1.3.2", features = ["bundled"] }
tiberius = { version = "0.12.3", features = ["tds73", "chrono"] }
tiberius = { version = "0.12.3", features = ["tds73", "chrono", "rust_decimal"] }
tokio-util = { version = "0.7", features = ["compat"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
futures = "0.3"

View File

@ -49,6 +49,8 @@ fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result<Vec<db:
is_nullable: row.get::<_, String>(2).unwrap_or_default() == "YES",
column_default: row.get::<_, Option<String>>(3)?,
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())

View File

@ -439,6 +439,8 @@ async fn get_columns_for_transfer(
is_primary_key: false,
extra: None,
comment: None,
numeric_precision: None,
numeric_scale: None,
})
}).map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())

View File

@ -112,6 +112,8 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu
column_default,
is_primary_key: is_pk,
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
}
}).collect())
}

View File

@ -31,6 +31,8 @@ pub struct ColumnInfo {
pub is_primary_key: bool,
pub extra: Option<String>,
pub comment: Option<String>,
pub numeric_precision: Option<i32>,
pub numeric_scale: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -1,4 +1,5 @@
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use rust_decimal::Decimal;
use sqlx::mysql::{MySqlPool, MySqlPoolOptions, MySqlRow};
use sqlx::{Column, Executor, Row, TypeInfo, ValueRef};
use std::time::{Duration, Instant};
@ -72,8 +73,8 @@ fn mysql_value_to_json(row: &MySqlRow, idx: usize, type_name: &str) -> serde_jso
if upper_type == "DECIMAL" {
return row
.try_get_unchecked::<String, _>(idx)
.map(serde_json::Value::String)
.try_get::<Decimal, _>(idx)
.map(|v: Decimal| serde_json::Value::String(v.to_string()))
.unwrap_or(serde_json::Value::Null);
}
@ -165,7 +166,8 @@ pub async fn get_columns(
) -> Result<Vec<ColumnInfo>, String> {
let rows: Vec<MySqlRow> = sqlx::query(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, c.COLUMN_COMMENT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA \
@ -191,6 +193,8 @@ pub async fn get_columns(
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
extra: get_opt_str(row, "EXTRA"),
comment: get_opt_str(row, "COLUMN_COMMENT").filter(|s| !s.is_empty()),
numeric_precision: row.get::<Option<i32>, _>("NUMERIC_PRECISION"),
numeric_scale: row.get::<Option<i32>, _>("NUMERIC_SCALE"),
})
.collect())
}

View File

@ -81,7 +81,7 @@ pub async fn get_columns(conn: &OracleClient, schema: &str, table: &str) -> Resu
let col_result = conn.query(
&format!(
"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE \
"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, DATA_PRECISION, DATA_SCALE \
FROM ALL_TAB_COLUMNS \
WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \
ORDER BY COLUMN_ID"
@ -98,6 +98,8 @@ pub async fn get_columns(conn: &OracleClient, schema: &str, table: &str) -> Resu
is_nullable: row.get_string(2).unwrap_or("N") == "Y",
column_default: None,
extra: None, comment: None,
numeric_precision: row.get_i64(3).map(|v| v as i32),
numeric_scale: row.get_i64(4).map(|v| v as i32),
}
}).collect())
}

View File

@ -1,4 +1,5 @@
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use rust_decimal::Decimal;
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
use sqlx::{Column, Executor, Row, TypeInfo, ValueRef};
use std::time::{Duration, Instant};
@ -48,8 +49,8 @@ fn pg_value_to_json(row: &PgRow, idx: usize, type_name: &str) -> serde_json::Val
if upper == "NUMERIC" || upper == "DECIMAL" || upper == "MONEY" {
return row
.try_get_unchecked::<String, _>(idx)
.map(serde_json::Value::String)
.try_get::<Decimal, _>(idx)
.map(|v: Decimal| serde_json::Value::String(v.to_string()))
.unwrap_or(serde_json::Value::Null);
}
@ -145,7 +146,8 @@ pub async fn get_columns(
let rows: Vec<PgRow> = sqlx::query(
"SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk, \
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) AS column_comment \
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) AS column_comment, \
c.numeric_precision, c.numeric_scale \
FROM information_schema.columns c \
LEFT JOIN information_schema.key_column_usage kcu \
ON c.table_schema = kcu.table_schema \
@ -174,6 +176,8 @@ pub async fn get_columns(
is_primary_key: row.get::<bool, _>("is_pk"),
extra: None,
comment: row.get::<Option<String>, _>("column_comment"),
numeric_precision: row.get::<Option<i32>, _>("numeric_precision"),
numeric_scale: row.get::<Option<i32>, _>("numeric_scale"),
})
.collect())
}

View File

@ -53,6 +53,8 @@ pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Resul
column_default: row.get::<Option<String>, _>("dflt_value"),
is_primary_key: row.get::<i32, _>("pk") > 0,
extra: None, comment: None,
numeric_precision: None,
numeric_scale: None,
})
.collect())
}

View File

@ -1,3 +1,4 @@
use rust_decimal::Decimal;
use tiberius::{AuthMethod, Client, Config};
use tokio::net::TcpStream;
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
@ -39,6 +40,8 @@ fn row_to_json(row: &tiberius::Row) -> Vec<serde_json::Value> {
(0..row.len()).map(|i| {
if let Some(v) = row.try_get::<&str, _>(i).ok().flatten() {
serde_json::Value::String(v.to_string())
} else if let Some(v) = row.try_get::<Decimal, _>(i).ok().flatten() {
serde_json::Value::String(v.to_string())
} else if let Some(v) = row.try_get::<i32, _>(i).ok().flatten() {
serde_json::Value::Number(v.into())
} else if let Some(v) = row.try_get::<i64, _>(i).ok().flatten() {
@ -93,7 +96,8 @@ pub async fn list_tables(client: &mut SqlServerClient, schema: &str) -> Result<V
pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE \
FROM INFORMATION_SCHEMA.COLUMNS c \
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \
@ -112,6 +116,8 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
extra: None, comment: None,
numeric_precision: row.get::<i32, _>(5),
numeric_scale: row.get::<i32, _>(6),
}
}).collect())
}

View File

@ -69,7 +69,14 @@ const columnTypeMap = computed(() => {
const map = new Map<string, string>();
if (props.tableMeta?.columns) {
for (const col of props.tableMeta.columns) {
map.set(col.name, shortTypeName(col.data_type));
const typeName = shortTypeName(col.data_type);
// Add precision for numeric/decimal types
if (col.numeric_precision != null && ["numeric", "decimal"].includes(col.data_type.toLowerCase())) {
const scale = col.numeric_scale ?? 0;
map.set(col.name, `${typeName}(${col.numeric_precision},${scale})`);
} else {
map.set(col.name, typeName);
}
}
}
return map;
@ -103,15 +110,16 @@ function shortTypeName(t: string): string {
}
function typeColorClass(t: string): string {
const s = t.toLowerCase();
if (["int", "int2", "int4", "int8", "smallint", "bigint", "integer", "serial", "bigserial", "tinyint", "mediumint"].includes(s)) return "text-blue-500";
if (["float4", "float8", "double", "decimal", "numeric", "real", "float", "money"].includes(s)) return "text-cyan-500";
if (["varchar", "text", "char", "character varying", "character", "string", "nvarchar", "nchar", "ntext", "longtext", "mediumtext", "tinytext", "clob"].includes(s)) return "text-green-500";
if (["bool", "boolean", "bit"].includes(s)) return "text-orange-500";
if (["timestamp", "timestamptz", "datetime", "date", "time", "timetz", "datetime2", "smalldatetime"].includes(s)) return "text-purple-500";
if (["json", "jsonb", "xml", "array"].includes(s)) return "text-pink-500";
if (["uuid", "uniqueidentifier"].includes(s)) return "text-amber-500";
if (["bytea", "blob", "binary", "varbinary", "image"].includes(s)) return "text-red-400";
// Strip precision/scale suffix like (20,6)
const base = t.replace(/\(.*\)$/, "").toLowerCase();
if (["int", "int2", "int4", "int8", "smallint", "bigint", "integer", "serial", "bigserial", "tinyint", "mediumint"].includes(base)) return "text-blue-500";
if (["float4", "float8", "double", "decimal", "numeric", "real", "float", "money"].includes(base)) return "text-cyan-500";
if (["varchar", "text", "char", "character varying", "character", "string", "nvarchar", "nchar", "ntext", "longtext", "mediumtext", "tinytext", "clob"].includes(base)) return "text-green-500";
if (["bool", "boolean", "bit"].includes(base)) return "text-orange-500";
if (["timestamp", "timestamptz", "datetime", "date", "time", "timetz", "datetime2", "smalldatetime"].includes(base)) return "text-purple-500";
if (["json", "jsonb", "xml", "array"].includes(base)) return "text-pink-500";
if (["uuid", "uniqueidentifier"].includes(base)) return "text-amber-500";
if (["bytea", "blob", "binary", "varbinary", "image"].includes(base)) return "text-red-400";
return "text-muted-foreground";
}
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);

View File

@ -41,6 +41,8 @@ export interface ColumnInfo {
is_primary_key: boolean;
extra: string | null;
comment?: string | null;
numeric_precision?: number | null;
numeric_scale?: number | null;
}
export interface IndexInfo {