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:
parent
76dfe9935d
commit
d1075c0042
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue