From 07d2eeb373bd353b45c6f6673b269f070b65dfe0 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:52:55 -0300 Subject: [PATCH] Read INT128-backed NUMERIC/DECIMAL values without overflowing or rounding NUMERIC/DECIMAL with precision 19-38 were converted to System.Decimal while the row was decoded: - Values beyond System.Decimal (e.g. 38 digits) threw OverflowException in the middle of decoding the row. On the managed (wire) client this left the rest of the packet unread and desynchronized the connection: every later command on it failed with "ArgumentOutOfRangeException: operation=...". - Values with large scale but small magnitude overflowed too, e.g. 1.000...0 as NUMERIC(38,30). - Values with more than 28 fractional digits were rounded silently, e.g. 1E-30 as NUMERIC(38,30) was read as 0. - The native client returned BigInteger for NUMERIC(38,0)/DECIMAL(38,0) although GetFieldType() reports System.Decimal (managed returned decimal). Now the value is converted exactly: values that fit System.Decimal are returned exactly as before (smallest scale, e.g. 1234.5), on both clients. Values that do not fit are kept exactly (internal BigDecimal), so the row is always read and the connection stays usable; for those values: - GetValue/GetDecimal/GetFieldValue throw OverflowException (only when that column is accessed), - GetString returns the exact value, - GetDouble/GetFloat return the nearest value, - GetFieldValue returns integral values exactly (also for values that fit decimal; fractional values throw OverflowException). As GetFieldValue and INT128 parameters share DbValue.GetInt128, an integral decimal parameter value can now be passed to an INT128 parameter (it threw InvalidCastException before). --- .../BigDecimalTests.cs | 114 +++++++++++++ .../FbLongNumericsSupportTests.cs | 103 ++++++++++++ .../Client/Managed/Version10/GdsStatement.cs | 8 + .../Client/Native/FesStatement.cs | 8 +- .../Common/BigDecimal.cs | 153 ++++++++++++++++++ .../Common/DbField.cs | 4 +- .../Common/DbValue.cs | 23 +++ .../Common/TypeDecoder.cs | 7 + 8 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/BigDecimalTests.cs create mode 100644 src/FirebirdSql.Data.FirebirdClient/Common/BigDecimal.cs diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/BigDecimalTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/BigDecimalTests.cs new file mode 100644 index 000000000..38b348742 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/BigDecimalTests.cs @@ -0,0 +1,114 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = F.D.Castel + +using System; +using System.Globalization; +using System.Numerics; +using FirebirdSql.Data.Common; +using FirebirdSql.Data.TestsBase; +using NUnit.Framework; + +namespace FirebirdSql.Data.FirebirdClient.Tests; + +[NoServerCategory] +public class BigDecimalTests +{ + static readonly object[] FitsDecimalSource = new object[] + { + new object[] { "0", 0, 0m }, + new object[] { "0", 30, 0m }, + new object[] { "1234500000", 6, 1234.5m }, + new object[] { "1234567", 6, 1.234567m }, + new object[] { "-1234567", 6, -1.234567m }, + new object[] { "1000000000000000000000000000000", 30, 1m }, + new object[] { "79228162514264337593543950335", 0, decimal.MaxValue }, + new object[] { "-79228162514264337593543950335", 0, decimal.MinValue }, + new object[] { "792281625142643375935439503350", 1, decimal.MaxValue }, + new object[] { "1", 28, 0.0000000000000000000000000001m }, + new object[] { "79228162514264337593543950335", 28, 7.9228162514264337593543950335m }, + }; + [TestCaseSource(nameof(FitsDecimalSource))] + public void CreatesDecimalWithSmallestScaleWhenExact(string unscaled, int scale, decimal expected) + { + var result = BigDecimal.Create(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale); + Assert.IsInstanceOf(result); + Assert.AreEqual(expected, (decimal)result); + Assert.AreEqual(expected.Scale, ((decimal)result).Scale); + } + + static readonly object[] DoesNotFitDecimalSource = new object[] + { + new object[] { "79228162514264337593543950336", 0, "79228162514264337593543950336" }, + new object[] { "-79228162514264337593543950336", 0, "-79228162514264337593543950336" }, + new object[] { "1", 29, "0.00000000000000000000000000001" }, + new object[] { "-123", 30, "-0.000000000000000000000000000123" }, + new object[] { "12345678901234567890123456789012345678", 6, "12345678901234567890123456789012.345678" }, + new object[] { "123456789012345678901234567890000", 3, "123456789012345678901234567890" }, + }; + [TestCaseSource(nameof(DoesNotFitDecimalSource))] + public void CreatesBigDecimalWhenNotExact(string unscaled, int scale, string expected) + { + var result = BigDecimal.Create(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale); + Assert.IsInstanceOf(result); + Assert.AreEqual(expected, ((BigDecimal)result).ToString(CultureInfo.InvariantCulture)); + Assert.Throws(() => Convert.ToDecimal(result, CultureInfo.InvariantCulture)); + Assert.AreEqual(double.Parse(expected, CultureInfo.InvariantCulture), Convert.ToDouble(result, CultureInfo.InvariantCulture)); + Assert.AreEqual(expected, Convert.ToString(result, CultureInfo.InvariantCulture)); + } + + [Test] + public void FormatsWithCulture() + { + var value = new BigDecimal(BigInteger.Parse("-12345678901234567890123456789012345678"), 6); + Assert.AreEqual("-12345678901234567890123456789012,345678", value.ToString(new CultureInfo("pt-BR"))); + Assert.AreEqual("-12345678901234567890123456789012.345678", value.ToString(new CultureInfo("en-US"))); + } + + [TestCase("25", 1, 2)] + [TestCase("35", 1, 4)] + [TestCase("-25", 1, -2)] + [TestCase("-35", 1, -4)] + [TestCase("26", 1, 3)] + [TestCase("24", 1, 2)] + [TestCase("123", 0, 123)] + public void ConvertsToIntegerRoundingToEven(string unscaled, int scale, long expected) + { + var value = new BigDecimal(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale); + Assert.AreEqual(expected, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + Assert.AreEqual((int)expected, Convert.ToInt32(value, CultureInfo.InvariantCulture)); + } + + [Test] + public void ConvertsToIntegerThrowsOnOverflow() + { + var value = new BigDecimal(BigInteger.Parse("12345678901234567890123456789012345678"), 0); + Assert.Throws(() => Convert.ToInt64(value, CultureInfo.InvariantCulture)); + } + + [TestCase("12345678901234567890123456789012345678", 0, true, "12345678901234567890123456789012345678")] + [TestCase("12345678901234567890123456789012000000", 6, true, "12345678901234567890123456789012")] + [TestCase("12345678901234567890123456789012345678", 6, false, null)] + public void ConvertsToBigIntegerOnlyWhenIntegral(string unscaled, int scale, bool expectedSuccess, string expected) + { + var value = new BigDecimal(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale); + Assert.AreEqual(expectedSuccess, value.TryToBigInteger(out var result)); + if (expectedSuccess) + { + Assert.AreEqual(BigInteger.Parse(expected, CultureInfo.InvariantCulture), result); + } + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbLongNumericsSupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbLongNumericsSupportTests.cs index b3de472c6..3d9ee474d 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbLongNumericsSupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbLongNumericsSupportTests.cs @@ -17,6 +17,7 @@ using System; using System.Globalization; +using System.Numerics; using System.Threading.Tasks; using FirebirdSql.Data.TestsBase; using NUnit.Framework; @@ -70,6 +71,108 @@ public async Task PassesValueCorrectly(decimal value, int scale) } } + static readonly object[] FitsDecimalValues = new[] + { + new object[] { "1.000000000000000000000000000000", 30, 1m }, + new object[] { "1234.500000", 6, 1234.5m }, + new object[] { "123", 0, 123m }, + new object[] { "79228162514264337593543950335", 0, decimal.MaxValue }, + new object[] { "-79228162514264337593543950335", 0, decimal.MinValue }, + new object[] { "0.0000000000000000000000000001", 28, 0.0000000000000000000000000001m }, + new object[] { "0.000000000000000000000000000100", 30, 0.0000000000000000000000000001m }, + }; + + [TestCaseSource(nameof(FitsDecimalValues))] + public async Task ReadsValueFittingDecimalExactly(string literal, int scale, decimal expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(typeof(decimal), reader.GetFieldType(0)); + var result = reader.GetValue(0); + Assert.IsInstanceOf(result); + Assert.AreEqual(expected, (decimal)result); + Assert.AreEqual(expected.ToString(CultureInfo.InvariantCulture), ((decimal)result).ToString(CultureInfo.InvariantCulture)); + } + } + } + + static readonly object[] DoesNotFitDecimalValues = new[] + { + new object[] { "12345678901234567890123456789012345678", 0 }, + new object[] { "-12345678901234567890123456789012345678", 0 }, + new object[] { "79228162514264337593543950336", 0 }, + new object[] { "12345678901234567890123456789012.345678", 6 }, + new object[] { "0.000000000000000000000000000001", 30 }, + new object[] { "-0.000123456789012345678901234567", 30 }, + }; + + [TestCaseSource(nameof(DoesNotFitDecimalValues))] + public async Task ReadsValueNotFittingDecimalWithoutFailingTheRow(string literal, int scale) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})), 1 from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(1, reader.GetInt32(1)); + var format = NumberFormatInfo.CurrentInfo; + Assert.AreEqual(literal.Replace("-", format.NegativeSign).Replace(".", format.NumberDecimalSeparator), reader.GetString(0)); + Assert.AreEqual(double.Parse(literal, CultureInfo.InvariantCulture), reader.GetDouble(0)); + Assert.Throws(() => reader.GetValue(0)); + Assert.Throws(() => reader.GetDecimal(0)); + Assert.Throws(() => reader.GetFieldValue(0)); + } + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast('1234.5' as numeric(38, 6)) from rdb$database"; + Assert.AreEqual(1234.5m, await cmd.ExecuteScalarAsync(), "connection must remain usable"); + } + } + + static readonly object[] BigIntegerValues = new[] + { + new object[] { "12345678901234567890123456789012345678", 0, "12345678901234567890123456789012345678" }, + new object[] { "-12345678901234567890123456789012345678", 0, "-12345678901234567890123456789012345678" }, + new object[] { "123", 0, "123" }, + new object[] { "12345678901234567890123456789012.000000", 6, "12345678901234567890123456789012" }, + new object[] { "1234.000000", 6, "1234" }, + }; + + [TestCaseSource(nameof(BigIntegerValues))] + public async Task ReadsIntegralValueAsBigInteger(string literal, int scale, string expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(BigInteger.Parse(expected, CultureInfo.InvariantCulture), reader.GetFieldValue(0)); + } + } + } + + [TestCase("1234.5", 6)] + [TestCase("12345678901234567890123456789012.5", 6)] + public async Task ReadsFractionalValueAsBigIntegerThrows(string literal, int scale) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.Throws(() => reader.GetFieldValue(0)); + } + } + } + [Test] public async Task ReadsValueNullCorrectly() { diff --git a/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs b/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs index 54b5698cc..487f34613 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs @@ -1569,6 +1569,10 @@ protected object ReadRawValue(IXdrReader xdr, DbField field) case DbDataType.Decimal: case DbDataType.Numeric: + if ((field.DataType & ~1) == IscCodes.SQL_INT128) + { + return TypeDecoder.DecodeInt128Numeric(xdr.ReadInt128(), field.NumericScale); + } return xdr.ReadDecimal(field.DataType, field.NumericScale); case DbDataType.Float: @@ -1667,6 +1671,10 @@ protected async ValueTask ReadRawValueAsync(IXdrReader xdr, DbField fiel case DbDataType.Decimal: case DbDataType.Numeric: + if ((field.DataType & ~1) == IscCodes.SQL_INT128) + { + return TypeDecoder.DecodeInt128Numeric(await xdr.ReadInt128Async(cancellationToken).ConfigureAwait(false), field.NumericScale); + } return await xdr.ReadDecimalAsync(field.DataType, field.NumericScale, cancellationToken).ConfigureAwait(false); case DbDataType.Float: diff --git a/src/FirebirdSql.Data.FirebirdClient/Client/Native/FesStatement.cs b/src/FirebirdSql.Data.FirebirdClient/Client/Native/FesStatement.cs index 67a86c7cd..1957b4b0f 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Client/Native/FesStatement.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Client/Native/FesStatement.cs @@ -413,7 +413,7 @@ public override void Execute(int timeout, IDescriptorFiller descriptorFiller) for (var i = 0; i < values.Length; i++) { var d = descriptor[i]; - var value = d.DbValue.GetValue(); + var value = d.DbValue.GetDecodedValue(); values[i] = new DbValue(this, d, value); } @@ -485,7 +485,7 @@ public override async ValueTask ExecuteAsync(int timeout, IDescriptorFiller desc for (var i = 0; i < values.Length; i++) { var d = descriptor[i]; - var value = await d.DbValue.GetValueAsync(cancellationToken).ConfigureAwait(false); + var value = await d.DbValue.GetDecodedValueAsync(cancellationToken).ConfigureAwait(false); values[i] = new DbValue(this, d, value); } @@ -573,7 +573,7 @@ public override DbValue[] Fetch() for (var i = 0; i < row.Length; i++) { var d = _fields[i]; - var value = d.DbValue.GetValue(); + var value = d.DbValue.GetDecodedValue(); row[i] = new DbValue(this, d, value); } return row; @@ -643,7 +643,7 @@ public override async ValueTask FetchAsync(CancellationToken cancella for (var i = 0; i < row.Length; i++) { var d = _fields[i]; - var value = await d.DbValue.GetValueAsync(cancellationToken).ConfigureAwait(false); + var value = await d.DbValue.GetDecodedValueAsync(cancellationToken).ConfigureAwait(false); row[i] = new DbValue(this, d, value); } return row; diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/BigDecimal.cs b/src/FirebirdSql.Data.FirebirdClient/Common/BigDecimal.cs new file mode 100644 index 000000000..fca1a52d4 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Common/BigDecimal.cs @@ -0,0 +1,153 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = F.D.Castel + +using System; +using System.Globalization; +using System.Numerics; + +namespace FirebirdSql.Data.Common; + +// Exact value of an INT128-backed NUMERIC/DECIMAL (value = Unscaled / 10^Scale) that cannot be represented +// exactly as System.Decimal. Created only when that conversion is impossible, so ToDecimal always throws; +// the exact value is available through ToString and approximations through ToDouble/ToSingle. +internal readonly struct BigDecimal : IConvertible +{ + public BigInteger Unscaled { get; } + public int Scale { get; } + + public BigDecimal(BigInteger unscaled, int scale) + { + Unscaled = unscaled; + Scale = scale; + } + + // Returns unscaled / 10^scale as System.Decimal when that is exact, otherwise as a BigDecimal. + // Either way the smallest scale that represents the value is used (trailing zeros removed), + // which is what the previous decimal-only decoding produced for the values it could handle. + public static object Create(BigInteger unscaled, int scale) + { + if (unscaled.IsZero) + { + return 0m; + } + while (scale > 0) + { + var quotient = BigInteger.DivRem(unscaled, 10, out var remainder); + if (!remainder.IsZero) + break; + unscaled = quotient; + scale--; + } + var magnitude = BigInteger.Abs(unscaled); + if (scale > 28 || scale < 0 || magnitude > MaxDecimalMagnitude) + { + return new BigDecimal(unscaled, scale); + } + var bits = (UInt128)magnitude; + return new decimal((int)(uint)bits, (int)(uint)(bits >> 32), (int)(uint)(bits >> 64), unscaled.Sign < 0, (byte)scale); + } + + public bool TryToBigInteger(out BigInteger result) + { + if (Scale <= 0) + { + result = Unscaled * BigInteger.Pow(10, -Scale); + return true; + } + result = BigInteger.DivRem(Unscaled, BigInteger.Pow(10, Scale), out var remainder); + return remainder.IsZero; + } + + static readonly BigInteger MaxDecimalMagnitude = (BigInteger)decimal.MaxValue; + + public override string ToString() => ToString(CultureInfo.CurrentCulture); + + public string ToString(IFormatProvider provider) + { + var format = NumberFormatInfo.GetInstance(provider); + var digits = BigInteger.Abs(Unscaled).ToString(CultureInfo.InvariantCulture); + var sign = Unscaled.Sign < 0 ? format.NegativeSign : string.Empty; + if (Scale <= 0) + { + return sign + digits + new string('0', -Scale); + } + if (digits.Length <= Scale) + { + digits = new string('0', Scale - digits.Length + 1) + digits; + } + return sign + digits.Substring(0, digits.Length - Scale) + format.NumberDecimalSeparator + digits.Substring(digits.Length - Scale); + } + + OverflowException CannotConvert(string type) + { + return new OverflowException($"Value {ToString(CultureInfo.InvariantCulture)} cannot be represented exactly as {type}."); + } + + // Rounds to the nearest integer, ties to even (same as Convert.ToInt64(decimal)). + BigInteger ToInteger() + { + if (Scale <= 0) + return Unscaled * BigInteger.Pow(10, -Scale); + var divisor = BigInteger.Pow(10, Scale); + var quotient = BigInteger.DivRem(Unscaled, divisor, out var remainder); + var twice = BigInteger.Abs(remainder) * 2; + if (twice > divisor || (twice == divisor && !quotient.IsEven)) + { + quotient += Unscaled.Sign; + } + return quotient; + } + + public TypeCode GetTypeCode() => TypeCode.Object; + public bool ToBoolean(IFormatProvider provider) => !Unscaled.IsZero; + public char ToChar(IFormatProvider provider) => throw new InvalidCastException($"Invalid cast from {nameof(BigDecimal)} to {nameof(Char)}."); + public DateTime ToDateTime(IFormatProvider provider) => throw new InvalidCastException($"Invalid cast from {nameof(BigDecimal)} to {nameof(DateTime)}."); + public decimal ToDecimal(IFormatProvider provider) => throw CannotConvert(nameof(Decimal)); + public double ToDouble(IFormatProvider provider) => double.Parse(ToString(CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture); + public float ToSingle(IFormatProvider provider) => float.Parse(ToString(CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture); + public sbyte ToSByte(IFormatProvider provider) => (sbyte)ToInteger(); + public byte ToByte(IFormatProvider provider) => (byte)ToInteger(); + public short ToInt16(IFormatProvider provider) => (short)ToInteger(); + public ushort ToUInt16(IFormatProvider provider) => (ushort)ToInteger(); + public int ToInt32(IFormatProvider provider) => (int)ToInteger(); + public uint ToUInt32(IFormatProvider provider) => (uint)ToInteger(); + public long ToInt64(IFormatProvider provider) => (long)ToInteger(); + public ulong ToUInt64(IFormatProvider provider) => (ulong)ToInteger(); + public object ToType(Type conversionType, IFormatProvider provider) + { + if (conversionType == typeof(BigDecimal) || conversionType == typeof(object)) + return this; + if (conversionType == typeof(string)) + return ToString(provider); + return Type.GetTypeCode(conversionType) switch + { + TypeCode.Boolean => ToBoolean(provider), + TypeCode.SByte => ToSByte(provider), + TypeCode.Byte => ToByte(provider), + TypeCode.Int16 => ToInt16(provider), + TypeCode.UInt16 => ToUInt16(provider), + TypeCode.Int32 => ToInt32(provider), + TypeCode.UInt32 => ToUInt32(provider), + TypeCode.Int64 => ToInt64(provider), + TypeCode.UInt64 => ToUInt64(provider), + TypeCode.Single => ToSingle(provider), + TypeCode.Double => ToDouble(provider), + TypeCode.Decimal => ToDecimal(provider), + _ => throw new InvalidCastException($"Invalid cast from {nameof(BigDecimal)} to {conversionType}."), + }; + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs index 3be89be74..f287f583a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs @@ -450,9 +450,9 @@ public void SetValue(byte[] buffer) break; case IscCodes.SQL_INT128: - if (_numericScale < 0) + if (DbDataType is DbDataType.Numeric or DbDataType.Decimal) { - DbValue.SetValue(TypeDecoder.DecodeDecimal(Int128Helper.GetInt128(buffer), _numericScale, _dataType)); + DbValue.SetValue(TypeDecoder.DecodeInt128Numeric(Int128Helper.GetInt128(buffer), _numericScale)); } else { diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 44b79a0f1..9a399aaed 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -95,6 +95,11 @@ public object GetValue() } default: + if (_value is BigDecimal bigDecimal) + { + // the field's CLR type is System.Decimal, which cannot hold this value exactly: throws + return bigDecimal.ToDecimal(CultureInfo.InvariantCulture); + } return _value; } } @@ -138,10 +143,25 @@ public async ValueTask GetValueAsync(CancellationToken cancellationToken } default: + if (_value is BigDecimal bigDecimal) + { + // the field's CLR type is System.Decimal, which cannot hold this value exactly: throws + return bigDecimal.ToDecimal(CultureInfo.InvariantCulture); + } return _value; } } + // Like GetValue, but keeps a BigDecimal as is (GetValue throws for it); used to copy decoded values into rows. + public object GetDecodedValue() + { + return _value is BigDecimal ? _value : GetValue(); + } + public ValueTask GetDecodedValueAsync(CancellationToken cancellationToken = default) + { + return _value is BigDecimal ? new ValueTask(_value) : GetValueAsync(cancellationToken); + } + public void SetValue(object value) { _value = value; @@ -273,6 +293,9 @@ public BigInteger GetInt128() short s => s, int i => i, long l => l, + decimal d when decimal.Truncate(d) == d => new BigInteger(d), + BigDecimal bd when bd.TryToBigInteger(out var bi) => bi, + decimal or BigDecimal => throw new OverflowException($"Value {Convert.ToString(_value, CultureInfo.InvariantCulture)} cannot be represented exactly as {nameof(BigInteger)}."), _ => (BigInteger)_value, }; } diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/TypeDecoder.cs b/src/FirebirdSql.Data.FirebirdClient/Common/TypeDecoder.cs index f7d058520..2552a09b2 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/TypeDecoder.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/TypeDecoder.cs @@ -24,6 +24,13 @@ namespace FirebirdSql.Data.Common; internal static class TypeDecoder { + // INT128-backed NUMERIC/DECIMAL: System.Decimal when the value fits exactly (the common case), + // otherwise a BigDecimal, so that reading the row never fails and never rounds silently. + public static object DecodeInt128Numeric(BigInteger value, int scale) + { + return BigDecimal.Create(value, scale < 0 ? -scale : scale); + } + public static decimal DecodeDecimal(object value, int scale, int type) { var shift = scale < 0 ? -scale : scale;