From d1075c004208ade2a9c459a391e833fb8dd3441b Mon Sep 17 00:00:00 2001 From: kingcanfish Date: Sat, 2 May 2026 10:14:01 +0800 Subject: [PATCH] fix: resolve PostgreSQL/MySQL/SQL Server NUMERIC/DECIMAL display issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rust_decimal feature to sqlx and tiberius for proper Decimal type handling - Fix try_get_unchecked 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 attempted to decode binary bytes as UTF-8, causing decode failures (NULL) or garbage characters (乱码). --- src-tauri/Cargo.lock | 5 +++++ src-tauri/Cargo.toml | 5 +++-- src-tauri/src/commands/schema.rs | 2 ++ src-tauri/src/commands/transfer.rs | 2 ++ src-tauri/src/db/clickhouse_driver.rs | 2 ++ src-tauri/src/db/mod.rs | 2 ++ src-tauri/src/db/mysql.rs | 10 +++++++--- src-tauri/src/db/oracle_driver.rs | 4 +++- src-tauri/src/db/postgres.rs | 10 +++++++--- src-tauri/src/db/sqlite.rs | 2 ++ src-tauri/src/db/sqlserver.rs | 8 +++++++- src/components/grid/DataGrid.vue | 28 +++++++++++++++++---------- src/types/database.ts | 2 ++ 13 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 000becf97..d05679a05 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c77a92828..9657a990e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index d87c44671..2aab53741 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -49,6 +49,8 @@ fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result(2).unwrap_or_default() == "YES", column_default: row.get::<_, Option>(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()) diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 6f0babeca..8eb340208 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -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()) diff --git a/src-tauri/src/db/clickhouse_driver.rs b/src-tauri/src/db/clickhouse_driver.rs index a3f864638..b88855ed2 100644 --- a/src-tauri/src/db/clickhouse_driver.rs +++ b/src-tauri/src/db/clickhouse_driver.rs @@ -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()) } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 14ce2d436..2c0e96630 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -31,6 +31,8 @@ pub struct ColumnInfo { pub is_primary_key: bool, pub extra: Option, pub comment: Option, + pub numeric_precision: Option, + pub numeric_scale: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 82a09fff8..f6b926de2 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -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::(idx) - .map(serde_json::Value::String) + .try_get::(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, String> { let rows: Vec = 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::("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::, _>("NUMERIC_PRECISION"), + numeric_scale: row.get::, _>("NUMERIC_SCALE"), }) .collect()) } diff --git a/src-tauri/src/db/oracle_driver.rs b/src-tauri/src/db/oracle_driver.rs index 35a5b7e4a..9af63c5c5 100644 --- a/src-tauri/src/db/oracle_driver.rs +++ b/src-tauri/src/db/oracle_driver.rs @@ -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()) } diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs index ea32b1a16..d3ae17745 100644 --- a/src-tauri/src/db/postgres.rs +++ b/src-tauri/src/db/postgres.rs @@ -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::(idx) - .map(serde_json::Value::String) + .try_get::(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 = 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::("is_pk"), extra: None, comment: row.get::, _>("column_comment"), + numeric_precision: row.get::, _>("numeric_precision"), + numeric_scale: row.get::, _>("numeric_scale"), }) .collect()) } diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs index c8a0585b1..228084008 100644 --- a/src-tauri/src/db/sqlite.rs +++ b/src-tauri/src/db/sqlite.rs @@ -53,6 +53,8 @@ pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Resul column_default: row.get::, _>("dflt_value"), is_primary_key: row.get::("pk") > 0, extra: None, comment: None, + numeric_precision: None, + numeric_scale: None, }) .collect()) } diff --git a/src-tauri/src/db/sqlserver.rs b/src-tauri/src/db/sqlserver.rs index e22fb74f6..7445bdaaa 100644 --- a/src-tauri/src/db/sqlserver.rs +++ b/src-tauri/src/db/sqlserver.rs @@ -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 { (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::(i).ok().flatten() { + serde_json::Value::String(v.to_string()) } else if let Some(v) = row.try_get::(i).ok().flatten() { serde_json::Value::Number(v.into()) } else if let Some(v) = row.try_get::(i).ok().flatten() { @@ -93,7 +96,8 @@ pub async fn list_tables(client: &mut SqlServerClient, schema: &str) -> Result Result, 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::(4).unwrap_or(0) == 1, extra: None, comment: None, + numeric_precision: row.get::(5), + numeric_scale: row.get::(6), } }).collect()) } diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 64a53f37c..b660073d8 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -69,7 +69,14 @@ const columnTypeMap = computed(() => { const map = new Map(); 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); diff --git a/src/types/database.ts b/src/types/database.ts index 529a76a09..c8377b673 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -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 {