From 722a3e3103ba517478f33d6b4565dd434116910a Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:59:23 +0100 Subject: [PATCH] Coerce decimal values to SqlDecimal on scale/precision change This is necessary because a normal System.Decimal value is limited to a precision of 29, while SQL Server's numeric type has a maximum precision of 38. --- .../src/Microsoft/Data/Common/AdapterUtil.cs | 3 -- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 42 +++++++++---------- .../ManualTests/AlwaysEncrypted/ApiShould.cs | 4 +- .../SQL/ParameterTest/ParametersTest.cs | 32 +++++++++++++- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs index e8ec7c9e0e..9ffd73398f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs @@ -1349,9 +1349,6 @@ internal static ArgumentOutOfRangeException InvalidParameterDirection(ParameterD internal static ArgumentException InvalidSizeValue(int value) => Argument(StringsHelper.GetString(Strings.ADP_InvalidSizeValue, value.ToString(CultureInfo.InvariantCulture))); - internal static ArgumentException ParameterValueOutOfRange(decimal value) - => ADP.Argument(StringsHelper.GetString(Strings.ADP_ParameterValueOutOfRange, value.ToString((IFormatProvider)null))); - internal static ArgumentException ParameterValueOutOfRange(SqlDecimal value) => ADP.Argument(StringsHelper.GetString(Strings.ADP_ParameterValueOutOfRange, value.ToString())); internal static ArgumentException ParameterValueOutOfRange(string value) => ADP.Argument(StringsHelper.GetString(Strings.ADP_ParameterValueOutOfRange, value)); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 8a00c41743..ef35137dcd 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -8306,7 +8306,7 @@ internal static SqlDecimal AdjustSqlDecimalScale(SqlDecimal d, int newScale) return d; } - internal static decimal AdjustDecimalScale(decimal value, int newScale) + internal static SqlDecimal AdjustDecimalScale(decimal value, int newScale) { #if NET Span decimalBits = stackalloc int[4]; @@ -8315,16 +8315,15 @@ internal static decimal AdjustDecimalScale(decimal value, int newScale) int[] decimalBits = decimal.GetBits(value); #endif int oldScale = (decimalBits[3] & 0x00ff0000) >> 0x10; + SqlDecimal num = new SqlDecimal(value); if (newScale != oldScale) { bool round = !LocalAppContextSwitches.TruncateScaledDecimal; - SqlDecimal num = new SqlDecimal(value); num = SqlDecimal.AdjustScale(num, newScale - oldScale, round); - return num.Value; } - return value; + return num; } internal byte[] SerializeSqlDecimal(SqlDecimal d, TdsParserStateObject stateObj) @@ -10457,34 +10456,31 @@ private Task TDSExecuteRPCAddParameter(TdsParserStateObject stateObj, SqlParamet // bug 49512, make sure the value matches the scale the user enters if (!isNull) { + SqlDecimal adjustedValue; + if (isSqlVal) { - value = AdjustSqlDecimalScale((SqlDecimal)value, scale); - - // If Precision is specified, verify value precision vs param precision - if (precision != 0) - { - if (precision < ((SqlDecimal)value).Precision) - { - throw ADP.ParameterValueOutOfRange((SqlDecimal)value); - } - } + adjustedValue = AdjustSqlDecimalScale((SqlDecimal)value, scale); } else { - value = AdjustDecimalScale((Decimal)value, scale); - - SqlDecimal sqlValue = new SqlDecimal((Decimal)value); + // If we encounter a System.Decimal at this point, we always convert it to + // a SqlDecimal. It's necessary in order to adjust the scale and to be able + // to transport the full range of numeric(38,X) values without overflowing. + adjustedValue = AdjustDecimalScale((decimal)value, scale); + isSqlVal = true; + } - // If Precision is specified, verify value precision vs param precision - if (precision != 0) + // If Precision is specified, verify value precision vs param precision + if (precision != 0) + { + if (precision < adjustedValue.Precision) { - if (precision < sqlValue.Precision) - { - throw ADP.ParameterValueOutOfRange((Decimal)value); - } + throw ADP.ParameterValueOutOfRange(adjustedValue); } } + + value = adjustedValue; } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs index 6f564250ce..c098d313aa 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ApiShould.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -175,7 +175,7 @@ public void SqlParameterProperties(string connection) string inputProcedureName = DataTestUtility.GetShortName("InputProc").ToString(); string outputProcedureName = DataTestUtility.GetShortName("OutputProc").ToString(); const int charColumnSize = 100; - const int decimalColumnPrecision = 10; + const int decimalColumnPrecision = 38; const int decimalColumnScale = 4; const int timeColumnScale = 5; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/ParametersTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/ParametersTest.cs index 95d3736d9a..bab48adb58 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/ParametersTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/ParametersTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -516,6 +516,36 @@ public static void TestScaledDecimalParameter_CommandInsert(string connectionStr Assert.True(ValidateInsertedValues(connection, decimalTable.Name, truncateScaledDecimal), $"Invalid test happened with connection string [{connection.ConnectionString}]"); } + [Fact] + public static void TestOutOfRangeDecimalParameter_CommandInsert() + { + using SqlConnection connection = new(DataTestUtility.TCPConnectionString); + connection.Open(); + + using SqlCommand cmd = new("SELECT @Value", connection); + // A System.Decimal value has a maximum precision of 29 digits. We specify a Precision of 38 and a Scale of 2 in order + // to prove that the client can change the scale and precision of the decimal value in ways which System.Decimal doesn't + // intrinsically support. + SqlParameter p = new("@Value", decimal.MaxValue) + { + Precision = 38, + Scale = 2 + }; + + cmd.Parameters.Add(p); + + using SqlDataReader reader = cmd.ExecuteReader(); + + reader.Read(); + // Read the original value back as a SqlDecimal, with matching scale and precision. + SqlDecimal roundtrippedDecimal = reader.GetSqlDecimal(0); + + Assert.Equal(38, roundtrippedDecimal.Precision); + Assert.Equal(2, roundtrippedDecimal.Scale); + Assert.Throws(() => roundtrippedDecimal.Value); + Assert.Equal($"{decimal.MaxValue}.00", roundtrippedDecimal.ToString()); + } + [Theory] [ClassData(typeof(ConnectionStringsProvider))] public static void TestScaledDecimalParameter_BulkCopy(string connectionString, bool truncateScaledDecimal)