From 0d2b6685d31600e071f67fe4359e37e8a1757a3e Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 20 Jul 2026 00:52:31 +0800 Subject: [PATCH] fix(sqlserver): display long varchar query results --- apps/desktop/src/components/grid/DataGrid.vue | 5 +-- apps/desktop/src/lib/dataGrid/cellValue.ts | 8 ++++ crates/dbx-core/src/db/sqlserver.rs | 28 ++++++++++--- .../tests/live_sqlserver_long_varchar.rs | 39 +++++++++++++++++++ packages/app-tests/cellValue.test.ts | 17 +++++++- 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 crates/dbx-core/tests/live_sqlserver_long_varchar.rs diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e7bec3175d..68f8f16154 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -106,7 +106,7 @@ import { visibleTransposeRecordWindow, } from "@/lib/dataGrid/dataGridTranspose"; import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus"; -import { displayCellValue, firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { displayCellValue, firstLineCellDisplayValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH, type CellValue } from "@/lib/dataGrid/cellValue"; import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistry"; import "@/lib/dataGrid/geometryMapPreview"; import { @@ -4141,7 +4141,6 @@ async function applyWhereFilter() { } } -const CELL_DISPLAY_MAX_LENGTH = 256; const CELL_FORMAT_CACHE_LIMIT = 20_000; const CELL_FORMAT_CACHE_PRUNE_COUNT = 5_000; @@ -4190,7 +4189,7 @@ function formatCell(value: CellValue, columnIndex?: number): string { const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnInfo?.data_type ?? (columnName ? columnTypeMap.value.get(columnName) : undefined)); if (binaryDisplay) return binaryDisplay; const s = applyColumnFormatter(value, formatter); - return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s; + return limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined); } function formatCellCached(value: CellValue, columnIndex?: number): string { diff --git a/apps/desktop/src/lib/dataGrid/cellValue.ts b/apps/desktop/src/lib/dataGrid/cellValue.ts index ef6b474a31..b61647b72e 100644 --- a/apps/desktop/src/lib/dataGrid/cellValue.ts +++ b/apps/desktop/src/lib/dataGrid/cellValue.ts @@ -1,5 +1,8 @@ export type CellValue = string | number | boolean | null; +export const DATA_GRID_CELL_DISPLAY_MAX_LENGTH = 256; +export const SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH = 8_000; + export function displayCellValue(value: CellValue): string { if (value === null) return "NULL"; if (typeof value === "boolean") return value ? "true" : "false"; @@ -11,3 +14,8 @@ export function firstLineCellDisplayValue(value: string): string { const lineBreakIndex = value.search(/\r\n|\r|\n/); return lineBreakIndex === -1 ? value : value.slice(0, lineBreakIndex); } + +export function limitDataGridCellDisplay(value: string, maxLength = DATA_GRID_CELL_DISPLAY_MAX_LENGTH): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength)}...`; +} diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 1d9e64fa7f..ba6739c449 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -9,7 +9,7 @@ use std::future::Future; use std::panic::AssertUnwindSafe; use std::sync::{Arc as StdArc, Mutex as StdMutex}; use std::time::{Duration, Instant}; -use tiberius::{AuthMethod, Client, ColumnData, Config, FromSql, QueryItem, QueryStream, SqlBrowser}; +use tiberius::{AuthMethod, Client, ColumnData, ColumnType, Config, FromSql, QueryItem, QueryStream, SqlBrowser}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tokio_util::sync::CancellationToken; @@ -194,11 +194,14 @@ fn columns_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec { metadata.columns().iter().map(|c| c.name().to_string()).collect() } -/// Map a tiberius column to a user-facing type name for the result-grid header. -/// Uses the TDS column-type debug name lowercased; good enough for display, with -/// no risk of mismatching the enum variants across tiberius versions. fn sqlserver_column_type_name(column: &tiberius::Column) -> String { - format!("{:?}", column.column_type()).to_lowercase() + match column.column_type() { + ColumnType::BigVarChar => "varchar".to_string(), + ColumnType::BigChar => "char".to_string(), + ColumnType::BigVarBin => "varbinary".to_string(), + ColumnType::BigBinary => "binary".to_string(), + column_type => format!("{column_type:?}").to_lowercase(), + } } fn column_types_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec { @@ -2096,7 +2099,7 @@ mod tests { }; use chrono::NaiveDate; use std::{borrow::Cow, time::Instant}; - use tiberius::{ColumnData, IntoSql}; + use tiberius::{Column, ColumnData, ColumnType, IntoSql}; #[tokio::test] async fn sqlserver_ignores_non_info_tiberius_events() { @@ -2166,6 +2169,19 @@ mod tests { ); } + #[test] + fn sqlserver_uses_sql_type_names_for_tds_big_types() { + for (column_type, expected) in [ + (ColumnType::BigVarChar, "varchar"), + (ColumnType::BigChar, "char"), + (ColumnType::BigVarBin, "varbinary"), + (ColumnType::BigBinary, "binary"), + ] { + let column = Column::new("value".to_string(), column_type); + assert_eq!(super::sqlserver_column_type_name(&column), expected); + } + } + #[test] fn sqlserver_endpoint_keeps_regular_hosts() { assert_eq!( diff --git a/crates/dbx-core/tests/live_sqlserver_long_varchar.rs b/crates/dbx-core/tests/live_sqlserver_long_varchar.rs new file mode 100644 index 0000000000..ec0477e30d --- /dev/null +++ b/crates/dbx-core/tests/live_sqlserver_long_varchar.rs @@ -0,0 +1,39 @@ +use dbx_core::db::sqlserver; +use std::time::Duration; + +#[tokio::test] +#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at SQL Server"] +async fn live_sqlserver_long_varchar_results_are_complete_and_use_sql_type_names() { + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + let database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "tempdb".to_string()); + let mut client = sqlserver::connect_with_port_explicit( + &host, + port, + true, + &user, + &password, + Some(&database), + Duration::from_secs(15), + ) + .await + .expect("connect to SQL Server"); + + let result = sqlserver::execute_query( + &mut client, + "SELECT CAST(REPLICATE('A', 250) + REPLICATE('B', 250) AS VARCHAR(500)) AS long_value \ + UNION ALL \ + SELECT CAST(REPLICATE('C', 500) AS VARCHAR(500))", + ) + .await + .expect("query VARCHAR(500) values"); + + assert_eq!(result.columns, vec!["long_value"]); + assert_eq!(result.column_types, vec!["varchar"]); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[0][0].as_str().map(str::len), Some(500)); + assert_eq!(result.rows[0][0], serde_json::json!(format!("{}{}", "A".repeat(250), "B".repeat(250)))); + assert_eq!(result.rows[1][0], serde_json::json!("C".repeat(500))); +} diff --git a/packages/app-tests/cellValue.test.ts b/packages/app-tests/cellValue.test.ts index c951582865..627abaf0ca 100644 --- a/packages/app-tests/cellValue.test.ts +++ b/packages/app-tests/cellValue.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { displayCellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts"; +import { DATA_GRID_CELL_DISPLAY_MAX_LENGTH, displayCellValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts"; test("displayCellValue returns NULL for null", () => { assert.equal(displayCellValue(null), "NULL"); @@ -27,6 +27,21 @@ test("displayCellValue does not truncate long strings", () => { assert.equal(displayCellValue(long), long); }); +test("limitDataGridCellDisplay preserves bounded SQL Server varchar values", () => { + const value = "a".repeat(500); + assert.equal(limitDataGridCellDisplay(value, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH), value); +}); + +test("limitDataGridCellDisplay marks oversized LOB previews as truncated", () => { + const value = "a".repeat(DATA_GRID_CELL_DISPLAY_MAX_LENGTH + 1); + assert.equal(limitDataGridCellDisplay(value), `${"a".repeat(DATA_GRID_CELL_DISPLAY_MAX_LENGTH)}...`); +}); + +test("limitDataGridCellDisplay preserves the default grid rendering limit", () => { + const value = "a".repeat(500); + assert.equal(limitDataGridCellDisplay(value), `${"a".repeat(DATA_GRID_CELL_DISPLAY_MAX_LENGTH)}...`); +}); + test("displayCellValue serializes JSON strings containing complex nested objects", () => { const payload = '{"metadata":{"version":"2.1.0","tags":["alpha","beta"],"flags":{"enabled":true,"nested":{"depth":3,"items":[{"id":1,"label":"a"},{"id":2,"label":"b"}]}}},"data":{"records":[{"key":"k-001","value":42},{"key":"k-002","value":-999}],"summary":{"total":3,"valid":2}}}'; assert.equal(displayCellValue(payload), payload);