From 343ef6fa8419a6e7fe4c38195016e2c9c1171d02 Mon Sep 17 00:00:00 2001 From: dienaso <89768641@qq.com> Date: Tue, 4 Aug 2026 13:02:30 +0800 Subject: [PATCH] fix(grid): normalize numeric values with thousand separators --- .../dataGrid/dataGridCellCoercion.spec.ts | 113 ++++++++++++++++++ .../src/lib/dataGrid/dataGridCellCoercion.ts | 37 ++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts index 088ebf1bd..1917432ee 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts @@ -65,4 +65,117 @@ describe("coerceDataGridCellValue", () => { expect(coerceDataGridCellValue(options)).toBeNull(); expect(coerceDataGridCellValue({ ...options, preserveEmptyString: true })).toBe(""); }); + + it("strips unambiguous thousands separators before numeric coercion", () => { + expect( + coerceDataGridCellValue({ + value: "1,234.50", + oldValue: 1234.5, + databaseType: "sqlserver", + columnInfo: { data_type: "float" }, + }), + ).toBe(1234.5); + + expect( + coerceDataGridCellValue({ + value: "1,234,567", + oldValue: 1234567, + databaseType: "sqlserver", + columnInfo: { data_type: "int" }, + }), + ).toBe(1234567); + + expect( + coerceDataGridCellValue({ + value: "-10,000.00", + oldValue: "-10000.00", + databaseType: "sqlserver", + columnInfo: { data_type: "decimal(18,2)" }, + }), + ).toBe("-10000.00"); + }); + + it("preserves exact text for grouped decimals", () => { + expect( + coerceDataGridCellValue({ + value: "10,000.00", + oldValue: "10000.50", + databaseType: "sqlserver", + columnInfo: { data_type: "decimal(18,2)" }, + }), + ).toBe("10000.00"); + }); + + it("normalizes grouped mantissas with scientific notation", () => { + expect( + coerceDataGridCellValue({ + value: "1,234.50e2", + oldValue: "0", + databaseType: "sqlserver", + columnInfo: { data_type: "decimal(18,2)" }, + }), + ).toBe("1234.50e2"); + + expect( + coerceDataGridCellValue({ + value: "-1,234.5E-2", + oldValue: "0", + databaseType: "sqlserver", + columnInfo: { data_type: "decimal(18,2)" }, + }), + ).toBe("-1234.5E-2"); + }); + + it("preserves exact text for grouped integers beyond Number.MAX_SAFE_INTEGER", () => { + expect( + coerceDataGridCellValue({ + value: "9,007,199,254,740,993", + oldValue: 9007199254740992, + databaseType: "mysql", + columnInfo: { data_type: "bigint" }, + }), + ).toBe("9007199254740993"); + }); + + it("leaves ambiguous single-group values untouched", () => { + expect( + coerceDataGridCellValue({ + value: "10,000", + oldValue: 10000, + databaseType: "sqlserver", + columnInfo: { data_type: "int" }, + }), + ).toBe("10,000"); + + expect( + coerceDataGridCellValue({ + value: "1,000e3", + oldValue: 1000000, + databaseType: "sqlserver", + columnInfo: { data_type: "float" }, + }), + ).toBe("1,000e3"); + }); + + it("does not strip commas when the column is not numeric", () => { + expect( + coerceDataGridCellValue({ + value: "10,000.00", + oldValue: "10,000.00", + databaseType: "sqlserver", + columnInfo: { data_type: "varchar(255)" }, + }), + ).toBe("10,000.00"); + }); + + it("leaves invalid thousand-grouping values untouched", () => { + expect( + coerceDataGridCellValue({ + value: "1,23", + oldValue: 123, + databaseType: "sqlserver", + columnInfo: { data_type: "decimal(18,2)" }, + }), + ).toBe("1,23"); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts index b9c946887..5321a35c8 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts @@ -1,5 +1,6 @@ import type { GridCellValue } from "@/lib/dataGrid/dataGridSql"; import type { DatabaseType, ColumnInfo } from "@/types/database"; +import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; export interface CoerceDataGridCellValueOptions { value: string; @@ -14,12 +15,17 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions) if (value === "" && oldValue === null && !options.preserveEmptyString) return null; const postgresArrayValue = coercePostgresArrayValue(options); if (postgresArrayValue !== undefined) return postgresArrayValue; + // Excel-pasted values often carry thousands separators (10,000.00) that make + // Number() return NaN and the literal fail to convert on the server. Strip + // only unambiguous groupings and keep the normalized text for the precision + // checks below, so exact values survive as text. + const numericText = normalizeGroupedNumberText(value, options.columnInfo); if (typeof oldValue === "number") { - const num = Number(value); + const num = Number(numericText); if (!Number.isNaN(num)) { - if (shouldPreserveNumericText(options, num)) { + if (shouldPreserveNumericText(options, num, numericText)) { // Keep precision-sensitive numeric edits as text; JS Number rounds 64-bit integers. - const text = value.trim(); + const text = numericText.trim(); if (text === String(oldValue)) return oldValue; return text; } @@ -27,9 +33,9 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions) } } if (typeof oldValue === "boolean") { - return value === "true" || value === "1"; + return numericText === "true" || numericText === "1"; } - return normalizeSmartQuotedJsonInput(value); + return normalizeSmartQuotedJsonInput(numericText); } export function dataGridCellEditorText(options: { value: GridCellValue | undefined; databaseType: DatabaseType | undefined; columnInfo: Pick | undefined }): string { @@ -92,12 +98,29 @@ function isPostgresArrayColumn(columnInfo: Pick | undef return dataType === "array" || dataType.endsWith("[]") || dataType.startsWith("_"); } -function shouldPreserveNumericText(options: CoerceDataGridCellValueOptions, parsedNumber: number): boolean { - const text = options.value.trim(); +function shouldPreserveNumericText(options: CoerceDataGridCellValueOptions, parsedNumber: number, text: string): boolean { if (!isNumericLiteralText(text)) return false; return shouldPreserveNumericTextForType(options.columnInfo?.data_type, text, parsedNumber); } +function normalizeGroupedNumberText(value: string, columnInfo: Pick | undefined): string { + if (!isNumericColumnType(columnInfo?.data_type)) return value; + return stripUnambiguousThousandSeparators(value); +} + +function stripUnambiguousThousandSeparators(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^([+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?)([eE][+-]?\d+)?$/); + if (!match) return value; + const mantissa = match[1]; + const exponent = match[2] ?? ""; + // A lone "1,000" (one comma group, no decimal point) is ambiguous: in + // comma-decimal locales it reads as 1.000. Only strip when a decimal point + // is present (1,234.56) or there are multiple comma groups (1,234,567). + if (/^[+-]?\d{1,3},\d{3}$/.test(mantissa)) return value; + return `${mantissa.replace(/,/g, "")}${exponent}`; +} + function postgresArrayElementDataType(dataType: string | undefined): string { const normalized = normalizeDataType(dataType); if (normalized.startsWith("_")) return normalized.slice(1);