From c1cf2d2d291c86ac8e2b0225711a213c14190606 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:39:52 -0300 Subject: [PATCH 1/5] Do not silently corrupt DECFLOAT values that do not fit the target format DecimalCodec.EncodeDecimal wrote coefficients with more digits than the format allows, and exponents outside its range, without any check (only a Debug.Assert). In Release builds the excess bits overflowed into the combination/exponent fields and the server stored a different value, e.g.: 12345678901234567890123456789012345 -> DECFLOAT(34) stored 8345678901234567890123456789012345 30000000000000004E-17 -> DECFLOAT(16) stored 0.08000000000000004 1E400 -> DECFLOAT(16) stored 8.000000000000001E-353 The encoder now brings the value into the format's ranges without changing its numeric value (dropping trailing zeros of the coefficient, folding the exponent into the coefficient, clamping the exponent of zero), and throws OverflowException when the value cannot be represented exactly (too many significant digits, exponent overflow or underflow). Values that previously got corrupted although representable (e.g. 1E384 in DECFLOAT(16)) now work. --- .../DecimalCodecTests.cs | 106 ++++++++++++++++++ .../FbDecFloat16SupportTests.cs | 26 +++++ .../FbDecFloat34SupportTests.cs | 26 +++++ .../Common/DecimalCodec.cs | 58 +++++++++- 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/DecimalCodecTests.cs diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/DecimalCodecTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/DecimalCodecTests.cs new file mode 100644 index 000000000..3c222bd07 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/DecimalCodecTests.cs @@ -0,0 +1,106 @@ +/* + * 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.Numerics; +using FirebirdSql.Data.Common; +using FirebirdSql.Data.TestsBase; +using FirebirdSql.Data.Types; +using NUnit.Framework; + +namespace FirebirdSql.Data.FirebirdClient.Tests; + +[NoServerCategory] +public class DecimalCodecTests +{ + static readonly object[] RepresentableDecFloat16Values = new object[] + { + new FbDecFloat(BigInteger.Parse("9999999999999999"), 369), + new FbDecFloat(BigInteger.Parse("-9999999999999999"), 369), + new FbDecFloat(1, -398), + new FbDecFloat(-1, -398), + // representable only after normalization + new FbDecFloat(1, 384), + new FbDecFloat(-1, 384), + new FbDecFloat(BigInteger.Parse("100000000000000000000"), -20), + new FbDecFloat(BigInteger.Parse("12345678901234560"), 0), + new FbDecFloat(100, -400), + new FbDecFloat(0, 1000), + new FbDecFloat(0, -1000), + }; + [TestCaseSource(nameof(RepresentableDecFloat16Values))] + public void DecFloat16EncodesRepresentableValueExactly(FbDecFloat value) + { + var result = DecimalCodec.DecFloat16.ParseBytes(DecimalCodec.DecFloat16.EncodeDecimal(value)); + Assert.AreEqual(value, result); + } + + static readonly object[] RepresentableDecFloat34Values = new object[] + { + new FbDecFloat(BigInteger.Parse("9999999999999999999999999999999999"), 6111), + new FbDecFloat(1, -6176), + // representable only after normalization + new FbDecFloat(1, 6144), + new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012340"), 0), + new FbDecFloat(BigInteger.Parse("-12345678901234567890123456789012340000"), -40), + new FbDecFloat(1000, -6179), + new FbDecFloat(0, int.MaxValue), + new FbDecFloat(0, int.MinValue), + }; + [TestCaseSource(nameof(RepresentableDecFloat34Values))] + public void DecFloat34EncodesRepresentableValueExactly(FbDecFloat value) + { + var result = DecimalCodec.DecFloat34.ParseBytes(DecimalCodec.DecFloat34.EncodeDecimal(value)); + Assert.AreEqual(value, result); + } + + [Test] + public void ZeroExponentIsClamped() + { + var result = DecimalCodec.DecFloat16.ParseBytes(DecimalCodec.DecFloat16.EncodeDecimal(new FbDecFloat(0, 1000))); + Assert.AreEqual(369, result.Exponent); + } + + static readonly object[] UnrepresentableDecFloat16Values = new object[] + { + new FbDecFloat(BigInteger.Parse("30000000000000004"), -17), + new FbDecFloat(BigInteger.Parse("-12345678901234567"), 0), + new FbDecFloat(1, 385), + new FbDecFloat(BigInteger.Parse("9999999999999999"), 370), + new FbDecFloat(1, -399), + new FbDecFloat(15, -400), + new FbDecFloat(1, int.MaxValue), + new FbDecFloat(1, int.MinValue), + }; + [TestCaseSource(nameof(UnrepresentableDecFloat16Values))] + public void DecFloat16ThrowsInsteadOfRounding(FbDecFloat value) + { + Assert.Throws(() => DecimalCodec.DecFloat16.EncodeDecimal(value)); + } + + static readonly object[] UnrepresentableDecFloat34Values = new object[] + { + new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012345"), 0), + new FbDecFloat(1, 6145), + new FbDecFloat(1, -6177), + }; + [TestCaseSource(nameof(UnrepresentableDecFloat34Values))] + public void DecFloat34ThrowsInsteadOfRounding(FbDecFloat value) + { + Assert.Throws(() => DecimalCodec.DecFloat34.EncodeDecimal(value)); + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs index 1fba53622..ecc0db499 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs @@ -63,8 +63,18 @@ public override async Task SetUp() new object[] { new FbDecFloat(-10, 34), "-100000000000000000000000000000000000" }, new object[] { new FbDecFloat(BigInteger.Parse("123000000001"), -10), "123.000000001E-1" }, new object[] { new FbDecFloat(BigInteger.Parse("-123000000001"), -10), "-123.000000001E-1" }, + new object[] { new FbDecFloat(1, 384), "1E384" }, + new object[] { new FbDecFloat(BigInteger.Parse("100000000000000000000"), -20), "1" }, + new object[] { new FbDecFloat(100, -400), "1E-398" }, }; + static readonly object[] UnrepresentableValues = new object[] + { + new FbDecFloat(BigInteger.Parse("30000000000000004"), -17), + new FbDecFloat(1, 385), + new FbDecFloat(1, -399), + }; + [TestCaseSource(nameof(TestValues))] public async Task ReadsValueCorrectly(FbDecFloat value, string castValue) { @@ -88,6 +98,22 @@ public async Task PassesValueCorrectly(FbDecFloat value, string dummy) } } + [TestCaseSource(nameof(UnrepresentableValues))] + public async Task PassesUnrepresentableValueThrows(FbDecFloat value) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(@value as decfloat(16)) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + Assert.ThrowsAsync(() => cmd.ExecuteScalarAsync()); + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(1 as decfloat(16)) from rdb$database"; + Assert.AreEqual((FbDecFloat)1, (FbDecFloat)await cmd.ExecuteScalarAsync(), "connection must remain usable"); + } + } + [Test] public async Task ReadsValueNullCorrectly() { diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs index b1c2bf39f..4e98d723e 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs @@ -63,8 +63,18 @@ public override async Task SetUp() new object[] { new FbDecFloat(-10, 34), "-100000000000000000000000000000000000" }, new object[] { new FbDecFloat(BigInteger.Parse("123000000001"), -10), "123.000000001E-1" }, new object[] { new FbDecFloat(BigInteger.Parse("-123000000001"), -10), "-123.000000001E-1" }, + new object[] { new FbDecFloat(1, 6144), "1E6144" }, + new object[] { new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012340"), 0), "12345678901234567890123456789012340" }, + new object[] { new FbDecFloat(1000, -6179), "1E-6176" }, }; + static readonly object[] UnrepresentableValues = new object[] + { + new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012345"), 0), + new FbDecFloat(1, 6145), + new FbDecFloat(1, -6177), + }; + [TestCaseSource(nameof(TestValues))] public async Task ReadsValueCorrectly(FbDecFloat value, string castValue) { @@ -88,6 +98,22 @@ public async Task PassesValueCorrectly(FbDecFloat value, string dummy) } } + [TestCaseSource(nameof(UnrepresentableValues))] + public async Task PassesUnrepresentableValueThrows(FbDecFloat value) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(@value as decfloat(34)) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + Assert.ThrowsAsync(() => cmd.ExecuteScalarAsync()); + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(1 as decfloat(34)) from rdb$database"; + Assert.AreEqual((FbDecFloat)1, (FbDecFloat)await cmd.ExecuteScalarAsync(), "connection must remain usable"); + } + } + [Test] public async Task ReadsValueNullCorrectly() { diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DecimalCodec.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DecimalCodec.cs index 5ff63ddee..8a7f8f9c6 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DecimalCodec.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DecimalCodec.cs @@ -17,6 +17,7 @@ using System; using System.Diagnostics; +using System.Numerics; using FirebirdSql.Data.Types; namespace FirebirdSql.Data.Common; @@ -41,6 +42,9 @@ public DecimalFormat(int formatBitLength, int coefficientDigits) ELimit = 3 * (1 << ExponentContinuationBits) - 1; EMin = -ELimit / 2; ExponentBias = -EMin + CoefficientDigits - 1; + MaxCoefficient = BigInteger.Pow(10, CoefficientDigits) - 1; + MinExponent = UnbiasedExponent(0); + MaxExponent = UnbiasedExponent(ELimit); } public int FormatBitLength { get; } @@ -51,6 +55,10 @@ public DecimalFormat(int formatBitLength, int coefficientDigits) public int ELimit { get; } public int EMin { get; } public int ExponentBias { get; } + public BigInteger MaxCoefficient { get; } + // Range of the exponent applied to the integral coefficient (value = coefficient * 10^exponent). + public int MinExponent { get; } + public int MaxExponent { get; } public void ValidateByteLength(byte[] decBytes) { @@ -171,8 +179,8 @@ public byte[] EncodeDecimal(FbDecFloat @decimal) void EncodeFinite(FbDecFloat @decimal, byte[] decBytes) { - var biasedExponent = _decimalFormat.BiasedExponent(@decimal.Exponent); - var coefficient = @decimal.Coefficient; + var (coefficient, exponent) = FitToFormat(@decimal); + var biasedExponent = _decimalFormat.BiasedExponent(exponent); var mostSignificantDigit = _coefficientCoder.EncodeValue(coefficient, decBytes); var expMSB = biasedExponent >>> _decimalFormat.ExponentContinuationBits; var expTwoBitCont = (biasedExponent >>> _decimalFormat.ExponentContinuationBits - 2) & 0b011; @@ -192,6 +200,52 @@ void EncodeFinite(FbDecFloat @decimal, byte[] decBytes) EncodeExponentContinuation(decBytes, biasedExponent, _decimalFormat.ExponentContinuationBits - 2); } + // Brings the coefficient and exponent into the ranges of the format without changing the numeric value + // (removing trailing zeros of the coefficient, or moving the exponent into the coefficient). + // Throws when that is impossible, i.e. when the value would have to be rounded, or overflows or underflows the format. + (BigInteger coefficient, int exponent) FitToFormat(FbDecFloat @decimal) + { + var coefficient = BigInteger.Abs(@decimal.Coefficient); + long exponent = @decimal.Exponent; + if (coefficient.IsZero) + { + return (coefficient, (int)Math.Clamp(exponent, _decimalFormat.MinExponent, _decimalFormat.MaxExponent)); + } + while (coefficient > _decimalFormat.MaxCoefficient) + { + coefficient = BigInteger.DivRem(coefficient, 10, out var remainder); + if (!remainder.IsZero) + { + throw CannotRepresent(@decimal, $"more than {_decimalFormat.CoefficientDigits} significant digits"); + } + exponent++; + } + while (exponent > _decimalFormat.MaxExponent) + { + coefficient *= 10; + if (coefficient > _decimalFormat.MaxCoefficient) + { + throw CannotRepresent(@decimal, "exponent overflow"); + } + exponent--; + } + while (exponent < _decimalFormat.MinExponent) + { + coefficient = BigInteger.DivRem(coefficient, 10, out var remainder); + if (!remainder.IsZero) + { + throw CannotRepresent(@decimal, "exponent underflow"); + } + exponent++; + } + return (coefficient, (int)exponent); + } + + OverflowException CannotRepresent(FbDecFloat @decimal, string reason) + { + return new OverflowException($"Value {@decimal} cannot be represented exactly as DECFLOAT({_decimalFormat.CoefficientDigits}): {reason}."); + } + static void EncodeExponentContinuation(byte[] decBytes, int expAndBias, int expBitsRemaining) { var expByteIndex = 1; From 1bb32cc71a9a60099b3e840eef9af70827b99d08 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:43:58 -0300 Subject: [PATCH 2/5] Fix FbDecFloat explicit conversions from decimal, double and float The conversions formatted the value with a fixed custom format and then removed the decimal point, assuming one was present. This threw ArgumentOutOfRangeException for every value without a fractional part, e.g. (FbDecFloat)1m, (FbDecFloat)100m, (FbDecFloat)2.0, and for values smaller than the format's decimal places, e.g. (FbDecFloat)1e-20. It also dropped the scale of decimals (1.10m became 1.1) and digits beyond 15 decimal places of doubles (7 for floats). - decimal: converted exactly from its 96-bit integer and scale, so the value and its scale are preserved (1.10m -> 110E-2), including -0. - double/float: converted to the shortest decimal that round-trips to the same binary value ("R" format), e.g. 0.1 -> 1E-1, 0.1 + 0.2 -> 30000000000000004E-17, 1e-20 -> 1E-20; -0.0 -> negative zero. A converted double may have up to 17 significant digits; sending it to a DECFLOAT(16) throws OverflowException instead of being silently rounded (this relies on the encoder range check of the previous commit). --- .../FbDecFloat16SupportTests.cs | 7 ++ .../FbDecFloat34SupportTests.cs | 7 ++ .../FbDecFloatTypeTests.cs | 106 ++++++++++++++++++ .../Types/FbDecFloat.cs | 38 +++++-- 4 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs index ecc0db499..c0570c6ee 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs @@ -66,6 +66,11 @@ public override async Task SetUp() new object[] { new FbDecFloat(1, 384), "1E384" }, new object[] { new FbDecFloat(BigInteger.Parse("100000000000000000000"), -20), "1" }, new object[] { new FbDecFloat(100, -400), "1E-398" }, + new object[] { (FbDecFloat)2.0, "2" }, + new object[] { (FbDecFloat)1e-20, "1E-20" }, + new object[] { (FbDecFloat)2.5f, "2.5" }, + new object[] { (FbDecFloat)100m, "100" }, + new object[] { (FbDecFloat)1.10m, "1.10" }, }; static readonly object[] UnrepresentableValues = new object[] @@ -73,6 +78,8 @@ public override async Task SetUp() new FbDecFloat(BigInteger.Parse("30000000000000004"), -17), new FbDecFloat(1, 385), new FbDecFloat(1, -399), + (FbDecFloat)(0.1 + 0.2), + (FbDecFloat)decimal.MaxValue, }; [TestCaseSource(nameof(TestValues))] diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs index 4e98d723e..32d0a26e8 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs @@ -66,6 +66,13 @@ public override async Task SetUp() new object[] { new FbDecFloat(1, 6144), "1E6144" }, new object[] { new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012340"), 0), "12345678901234567890123456789012340" }, new object[] { new FbDecFloat(1000, -6179), "1E-6176" }, + new object[] { (FbDecFloat)2.0, "2" }, + new object[] { (FbDecFloat)1e-20, "1E-20" }, + new object[] { (FbDecFloat)2.5f, "2.5" }, + new object[] { (FbDecFloat)(0.1 + 0.2), "0.30000000000000004" }, + new object[] { (FbDecFloat)100m, "100" }, + new object[] { (FbDecFloat)1.10m, "1.10" }, + new object[] { (FbDecFloat)decimal.MaxValue, "79228162514264337593543950335" }, }; static readonly object[] UnrepresentableValues = new object[] diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs index 4df15b2d5..441982582 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs @@ -15,6 +15,8 @@ //$Authors = Jiri Cincura (jiri@cincura.net) +using System; +using System.Globalization; using System.Numerics; using FirebirdSql.Data.TestsBase; using FirebirdSql.Data.Types; @@ -72,4 +74,108 @@ public void EqualityFalse(FbDecFloat expected, FbDecFloat actual) { Assert.AreNotEqual(expected, actual); } + + static readonly object[] DecimalConversionSource = new object[] + { + new object[] { 0m, "0", 0 }, + new object[] { 1m, "1", 0 }, + new object[] { 100m, "100", 0 }, + new object[] { -5m, "-5", 0 }, + new object[] { 1.5m, "15", -1 }, + new object[] { 1.10m, "110", -2 }, + new object[] { -0.001m, "-1", -3 }, + new object[] { decimal.MaxValue, "79228162514264337593543950335", 0 }, + new object[] { decimal.MinValue, "-79228162514264337593543950335", 0 }, + new object[] { 0.0000000000000000000000000001m, "1", -28 }, + new object[] { -7.9228162514264337593543950335m, "-79228162514264337593543950335", -28 }, + }; + [TestCaseSource(nameof(DecimalConversionSource))] + public void ConvertsDecimalExactly(decimal value, string coefficient, int exponent) + { + var result = (FbDecFloat)value; + Assert.AreEqual(BigInteger.Parse(coefficient), result.Coefficient); + Assert.AreEqual(exponent, result.Exponent); + } + + static readonly object[] DoubleConversionSource = new object[] + { + new object[] { 0d, "0", 0 }, + new object[] { 2d, "2", 0 }, + new object[] { 100d, "100", 0 }, + new object[] { 0.1, "1", -1 }, + new object[] { -6.6, "-66", -1 }, + new object[] { 1e-20, "1", -20 }, + new object[] { 1e300, "1", 300 }, + new object[] { 0.1 + 0.2, "30000000000000004", -17 }, + new object[] { 123456789.123456789, "12345678912345679", -8 }, + new object[] { double.MaxValue, "17976931348623157", 292 }, + new object[] { double.MinValue, "-17976931348623157", 292 }, + new object[] { double.Epsilon, "5", -324 }, + }; + [TestCaseSource(nameof(DoubleConversionSource))] + public void ConvertsDoubleToShortestRoundTripValue(double value, string coefficient, int exponent) + { + var result = (FbDecFloat)value; + Assert.AreEqual(BigInteger.Parse(coefficient), result.Coefficient); + Assert.AreEqual(exponent, result.Exponent); + } + + static readonly object[] SingleConversionSource = new object[] + { + new object[] { 2f, "2", 0 }, + new object[] { 0.1f, "1", -1 }, + new object[] { -6.6f, "-66", -1 }, + new object[] { 1e-20f, "1", -20 }, + new object[] { float.MaxValue, "34028235", 31 }, + new object[] { float.Epsilon, "1", -45 }, + }; + [TestCaseSource(nameof(SingleConversionSource))] + public void ConvertsSingleToShortestRoundTripValue(float value, string coefficient, int exponent) + { + var result = (FbDecFloat)value; + Assert.AreEqual(BigInteger.Parse(coefficient), result.Coefficient); + Assert.AreEqual(exponent, result.Exponent); + } + + [Test] + public void ConvertsNegativeZero() + { + Assert.AreEqual(FbDecFloat.NegativeZero, (FbDecFloat)(-0d)); + Assert.AreEqual(FbDecFloat.NegativeZero, (FbDecFloat)(-0f)); + Assert.AreEqual(FbDecFloat.NegativeZero, (FbDecFloat)decimal.Negate(0m)); + Assert.AreNotEqual(FbDecFloat.NegativeZero, (FbDecFloat)0d); + Assert.AreNotEqual(FbDecFloat.NegativeZero, (FbDecFloat)0m); + } + + [Test] + public void ConvertsSpecialValues() + { + Assert.AreEqual(FbDecFloat.PositiveNaN, (FbDecFloat)double.NaN); + Assert.AreEqual(FbDecFloat.PositiveInfinity, (FbDecFloat)double.PositiveInfinity); + Assert.AreEqual(FbDecFloat.NegativeInfinity, (FbDecFloat)double.NegativeInfinity); + Assert.AreEqual(FbDecFloat.PositiveNaN, (FbDecFloat)float.NaN); + Assert.AreEqual(FbDecFloat.PositiveInfinity, (FbDecFloat)float.PositiveInfinity); + Assert.AreEqual(FbDecFloat.NegativeInfinity, (FbDecFloat)float.NegativeInfinity); + } + + [Test] + public void DoubleAndSingleConversionsRoundTrip() + { + var random = new Random(20261107); + for (var i = 0; i < 10_000; i++) + { + var d = BitConverter.Int64BitsToDouble(random.NextInt64()); + if (double.IsFinite(d)) + { + var result = (FbDecFloat)d; + Assert.AreEqual(d, double.Parse(result.ToString(), CultureInfo.InvariantCulture), $"{d:R} -> {result}"); + } + var f = BitConverter.Int32BitsToSingle(random.Next(int.MinValue, int.MaxValue)); + if (float.IsFinite(f)) + { + var result = (FbDecFloat)f; + Assert.AreEqual(f, float.Parse(result.ToString(), CultureInfo.InvariantCulture), $"{f:R} -> {result}"); + } + } + } } diff --git a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs index 29c37c4ee..7c2c22c50 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs @@ -64,16 +64,27 @@ public FbDecFloat(BigInteger coefficient, int exponent = 0) float.NaN => PositiveNaN, float.PositiveInfinity => PositiveInfinity, float.NegativeInfinity => NegativeInfinity, - _ => ParseNumber(value, "0.#######"), + // shortest representation that round-trips to the same float + _ => ParseRoundTrip(value.ToString("R", CultureInfo.InvariantCulture)), }; public static explicit operator FbDecFloat(double value) => value switch { double.NaN => PositiveNaN, double.PositiveInfinity => PositiveInfinity, double.NegativeInfinity => NegativeInfinity, - _ => ParseNumber(value, "0.###############"), + // shortest representation that round-trips to the same double + _ => ParseRoundTrip(value.ToString("R", CultureInfo.InvariantCulture)), }; - public static explicit operator FbDecFloat(decimal value) => ParseNumber(value, "0.############################"); + public static explicit operator FbDecFloat(decimal value) + { + // exact: the decimal's 96-bit integer and scale are the coefficient and (negated) exponent + Span bits = stackalloc int[4]; + decimal.GetBits(value, bits); + BigInteger coefficient = ((UInt128)(uint)bits[2] << 64) | ((UInt128)(uint)bits[1] << 32) | (uint)bits[0]; + var negative = bits[3] < 0; + var scale = (bits[3] >> 16) & 0xFF; + return new FbDecFloat(DecimalType.Finite, negative, negative ? -coefficient : coefficient, -scale); + } public override string ToString() { @@ -155,10 +166,23 @@ public bool Equals(FbDecFloat other) return !lhs.Equals(rhs); } - static FbDecFloat ParseNumber(IFormattable formattable, string format) + // Parses the invariant "R" format of float/double, e.g. "2", "-0", "6.6", "1E-20", "1.7976931348623157E+308". + static FbDecFloat ParseRoundTrip(string s) { - var s = formattable.ToString(format, CultureInfo.InvariantCulture); - var pos = s.IndexOf('.'); - return new FbDecFloat(BigInteger.Parse(s.Remove(pos, 1), CultureInfo.InvariantCulture), -(s.Length - pos - 1)); + var exponent = 0; + var exponentPos = s.IndexOf('E'); + if (exponentPos >= 0) + { + exponent = int.Parse(s.AsSpan(exponentPos + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + s = s.Substring(0, exponentPos); + } + var pointPos = s.IndexOf('.'); + if (pointPos >= 0) + { + exponent -= s.Length - pointPos - 1; + s = s.Remove(pointPos, 1); + } + var coefficient = BigInteger.Parse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + return new FbDecFloat(DecimalType.Finite, s[0] == '-', coefficient, exponent); } } From d876f475ee0ff63877679ac2e406cfed3426be71 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 12:51:11 -0300 Subject: [PATCH 3/5] Cover negative doubles in the FbDecFloat round-trip property test Random.NextInt64() never returns a negative value, so the random doubles were all positive. Use the full long range and assert both signs are covered. --- .../FbDecFloatTypeTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs index 441982582..ad41cca5c 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs @@ -162,20 +162,27 @@ public void ConvertsSpecialValues() public void DoubleAndSingleConversionsRoundTrip() { var random = new Random(20261107); + var negativeDoubles = 0; + var negativeSingles = 0; for (var i = 0; i < 10_000; i++) { - var d = BitConverter.Int64BitsToDouble(random.NextInt64()); + // random bit patterns, so all exponents and both signs are covered + var d = BitConverter.Int64BitsToDouble(random.NextInt64(long.MinValue, long.MaxValue)); if (double.IsFinite(d)) { var result = (FbDecFloat)d; Assert.AreEqual(d, double.Parse(result.ToString(), CultureInfo.InvariantCulture), $"{d:R} -> {result}"); + negativeDoubles += d < 0 ? 1 : 0; } var f = BitConverter.Int32BitsToSingle(random.Next(int.MinValue, int.MaxValue)); if (float.IsFinite(f)) { var result = (FbDecFloat)f; Assert.AreEqual(f, float.Parse(result.ToString(), CultureInfo.InvariantCulture), $"{f:R} -> {result}"); + negativeSingles += f < 0 ? 1 : 0; } } + Assert.Greater(negativeDoubles, 1000); + Assert.Greater(negativeSingles, 1000); } } From 2f9500e6f31271b728a11fd927242ce7118091e4 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:59:03 -0300 Subject: [PATCH 4/5] Accept and return ordinary .NET numbers for DECFLOAT Parameters: a DECFLOAT parameter only accepted FbDecFloat; any other value (decimal, double, int, ...) threw InvalidCastException, even when the parameter was created by AddWithValue with a decimal. Values of type decimal, double, float, the integral types and BigInteger are now converted to FbDecFloat (decimal exactly, double/float to the shortest round-trip value). A value that does not fit the DECFLOAT precision exactly throws OverflowException instead of being rounded. Reader: GetDecimal, GetDouble, GetFloat, GetInt16/32/64 and GetByte (and the matching GetFieldValue) on a DECFLOAT column threw InvalidCastException because FbDecFloat is not IConvertible. Now: - GetDecimal: exact, keeping the scale (1.10 -> 1.10m); OverflowException when the value cannot be represented exactly (too large, too many digits, NaN, infinity). - GetDouble/GetFloat: nearest value, like on NUMERIC columns; NaN and infinities are mapped, out-of-range values become infinity or zero. - Integer getters: nearest integer, ties to even, as Convert does for decimal columns; OverflowException when out of range, NaN or infinity. FbDecFloat gets the matching public explicit conversions to decimal, double and float. --- .../FbDecFloat16SupportTests.cs | 147 ++++++++++++++++++ .../FbDecFloat34SupportTests.cs | 132 ++++++++++++++++ .../FbDecFloatTypeTests.cs | 75 +++++++++ .../Common/DbValue.cs | 40 ++++- .../Types/FbDecFloat.cs | 93 +++++++++++ 5 files changed, 483 insertions(+), 4 deletions(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs index c0570c6ee..449d625f5 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs @@ -16,6 +16,7 @@ //$Authors = Jiri Cincura (jiri@cincura.net) using System; +using System.Globalization; using System.Numerics; using System.Threading.Tasks; using FirebirdSql.Data.TestsBase; @@ -144,6 +145,152 @@ public async Task PassesValueNullCorrectly() } } + static readonly object[] ClrValues = new[] + { + new object[] { 2.5m, "25", -1 }, + new object[] { 1.10m, "110", -2 }, + new object[] { -100m, "-100", 0 }, + new object[] { 0.1, "1", -1 }, + new object[] { -6.6, "-66", -1 }, + new object[] { 2.5f, "25", -1 }, + new object[] { 42, "42", 0 }, + new object[] { -42L, "-42", 0 }, + new object[] { (short)7, "7", 0 }, + new object[] { (byte)7, "7", 0 }, + new object[] { 1234567890123456UL, "1234567890123456", 0 }, + new object[] { new BigInteger(123456789), "123456789", 0 }, + }; + + [TestCaseSource(nameof(ClrValues))] + public async Task PassesClrValueCorrectly(object value, string coefficient, int exponent) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(@value as decfloat(16)) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + var result = (FbDecFloat)await cmd.ExecuteScalarAsync(); + Assert.AreEqual(new FbDecFloat(BigInteger.Parse(coefficient, CultureInfo.InvariantCulture), exponent), result); + } + } + + static readonly object[] UnrepresentableClrValues = new object[] + { + ulong.MaxValue, + decimal.MaxValue, + 0.1 + 0.2, + 1.0 / 3.0 * 1e-300, + }; + + [TestCaseSource(nameof(UnrepresentableClrValues))] + public async Task PassesUnrepresentableClrValueThrows(object value) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(@value as decfloat(16)) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + Assert.ThrowsAsync(() => cmd.ExecuteScalarAsync()); + } + } + + [TestCase("1.10", "1.10")] + [TestCase("-123.456", "-123.456")] + [TestCase("1E+20", "100000000000000000000")] + [TestCase("0", "0")] + [TestCase("0.0000000000009999999999999999", "0.0000000000009999999999999999")] + public async Task ReadsAsDecimalExactly(string castValue, string expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(16)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetDecimal(0).ToString(CultureInfo.InvariantCulture)); + Assert.AreEqual(expected, reader.GetFieldValue(0).ToString(CultureInfo.InvariantCulture)); + } + } + } + + [TestCase("1E+29")] + [TestCase("-1E+29")] + [TestCase("1E-29")] + [TestCase("nan")] + [TestCase("inf")] + [TestCase("1E+300")] + public async Task ReadsAsDecimalThrowsWhenNotExact(string castValue) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(16)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.Throws(() => reader.GetDecimal(0)); + } + } + } + + [TestCase("1.10", 1.1)] + [TestCase("-6.6", -6.6)] + [TestCase("0.1", 0.1)] + [TestCase("nan", double.NaN)] + [TestCase("inf", double.PositiveInfinity)] + [TestCase("-inf", double.NegativeInfinity)] + [TestCase("1E-398", 0d)] + [TestCase("1E+300", 1E+300)] + public async Task ReadsAsDoubleNearest(string castValue, double expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(16)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetDouble(0)); + Assert.AreEqual((float)expected, reader.GetFloat(0)); + } + } + } + + [TestCase("123", 123)] + [TestCase("1E+3", 1000)] + [TestCase("2.5", 2)] + [TestCase("3.5", 4)] + [TestCase("-2.5", -2)] + [TestCase("-3.5", -4)] + [TestCase("2.51", 3)] + [TestCase("1E-300", 0)] + public async Task ReadsAsIntegerRoundingToEven(string castValue, long expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(16)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetInt64(0)); + Assert.AreEqual((int)expected, reader.GetInt32(0)); + Assert.AreEqual((short)expected, reader.GetInt16(0)); + } + } + } + + [TestCase("1E+19")] + [TestCase("nan")] + [TestCase("-inf")] + public async Task ReadsAsIntegerThrowsOnOverflow(string castValue) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(16)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.Throws(() => reader.GetInt64(0)); + } + } + } + [Test] public async Task SelectEmptyResultSet() { diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs index 32d0a26e8..5f765d23e 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs @@ -16,6 +16,7 @@ //$Authors = Jiri Cincura (jiri@cincura.net) using System; +using System.Globalization; using System.Numerics; using System.Threading.Tasks; using FirebirdSql.Data.TestsBase; @@ -144,6 +145,137 @@ public async Task PassesValueNullCorrectly() } } + static readonly object[] ClrValues = new[] + { + new object[] { 2.5m, "25", -1 }, + new object[] { 1.10m, "110", -2 }, + new object[] { -100m, "-100", 0 }, + new object[] { 0.1, "1", -1 }, + new object[] { -6.6, "-66", -1 }, + new object[] { 2.5f, "25", -1 }, + new object[] { 42, "42", 0 }, + new object[] { -42L, "-42", 0 }, + new object[] { (short)7, "7", 0 }, + new object[] { (byte)7, "7", 0 }, + new object[] { ulong.MaxValue, "18446744073709551615", 0 }, + new object[] { new BigInteger(123456789), "123456789", 0 }, + }; + + [TestCaseSource(nameof(ClrValues))] + public async Task PassesClrValueCorrectly(object value, string coefficient, int exponent) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select cast(@value as decfloat(34)) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + var result = (FbDecFloat)await cmd.ExecuteScalarAsync(); + Assert.AreEqual(new FbDecFloat(BigInteger.Parse(coefficient, CultureInfo.InvariantCulture), exponent), result); + } + } + + [TestCase("1.10", "1.10")] + [TestCase("-123.456", "-123.456")] + [TestCase("1E+20", "100000000000000000000")] + [TestCase("0", "0")] + [TestCase("0.0000000000009999999999999999", "0.0000000000009999999999999999")] + [TestCase("1234567890123456789012345678.9", "1234567890123456789012345678.9")] + [TestCase("7.9228162514264337593543950335", "7.9228162514264337593543950335")] + public async Task ReadsAsDecimalExactly(string castValue, string expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(34)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetDecimal(0).ToString(CultureInfo.InvariantCulture)); + Assert.AreEqual(expected, reader.GetFieldValue(0).ToString(CultureInfo.InvariantCulture)); + } + } + } + + [TestCase("1E+29")] + [TestCase("-1E+29")] + [TestCase("1E-29")] + [TestCase("nan")] + [TestCase("inf")] + [TestCase("1.0000000000000000000000000000001")] + [TestCase("79228162514264337593543950336")] + public async Task ReadsAsDecimalThrowsWhenNotExact(string castValue) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(34)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.Throws(() => reader.GetDecimal(0)); + } + } + } + + [TestCase("1.10", 1.1)] + [TestCase("-6.6", -6.6)] + [TestCase("0.1", 0.1)] + [TestCase("nan", double.NaN)] + [TestCase("inf", double.PositiveInfinity)] + [TestCase("-inf", double.NegativeInfinity)] + [TestCase("1E-6000", 0d)] + [TestCase("1E+6000", double.PositiveInfinity)] + [TestCase("0.30000000000000004", 0.30000000000000004)] + public async Task ReadsAsDoubleNearest(string castValue, double expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(34)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetDouble(0)); + Assert.AreEqual((float)expected, reader.GetFloat(0)); + } + } + } + + [TestCase("123", 123)] + [TestCase("1E+3", 1000)] + [TestCase("2.5", 2)] + [TestCase("3.5", 4)] + [TestCase("-2.5", -2)] + [TestCase("-3.5", -4)] + [TestCase("2.51", 3)] + [TestCase("1E-300", 0)] + public async Task ReadsAsIntegerRoundingToEven(string castValue, long expected) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(34)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.AreEqual(expected, reader.GetInt64(0)); + Assert.AreEqual((int)expected, reader.GetInt32(0)); + Assert.AreEqual((short)expected, reader.GetInt16(0)); + } + } + } + + [TestCase("1E+19")] + [TestCase("nan")] + [TestCase("-inf")] + public async Task ReadsAsIntegerThrowsOnOverflow(string castValue) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast('{castValue}' as decfloat(34)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + await reader.ReadAsync(); + Assert.Throws(() => reader.GetInt64(0)); + } + } + } + [Test] public async Task SelectEmptyResultSet() { diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs index ad41cca5c..e44e697ea 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatTypeTests.cs @@ -185,4 +185,79 @@ public void DoubleAndSingleConversionsRoundTrip() Assert.Greater(negativeDoubles, 1000); Assert.Greater(negativeSingles, 1000); } + static readonly object[] ToDecimalSource = new object[] + { + new object[] { new FbDecFloat(110, -2), "1.10" }, + new object[] { new FbDecFloat(-5, 0), "-5" }, + new object[] { new FbDecFloat(1, 28), "10000000000000000000000000000" }, + new object[] { new FbDecFloat(BigInteger.Parse("79228162514264337593543950335"), 0), "79228162514264337593543950335" }, + new object[] { new FbDecFloat(BigInteger.Parse("792281625142643375935439503350"), -1), "79228162514264337593543950335" }, + new object[] { new FbDecFloat(1, -28), "0.0000000000000000000000000001" }, + new object[] { new FbDecFloat(1000, -31), "0.0000000000000000000000000001" }, + new object[] { new FbDecFloat(0, -40), "0.0000000000000000000000000000" }, + new object[] { new FbDecFloat(0, 40), "0" }, + }; + [TestCaseSource(nameof(ToDecimalSource))] + public void ConvertsToDecimalExactly(FbDecFloat value, string expected) + { + Assert.AreEqual(expected, ((decimal)value).ToString(CultureInfo.InvariantCulture)); + } + + static readonly object[] ToDecimalThrowsSource = new object[] + { + new FbDecFloat(1, 29), + new FbDecFloat(BigInteger.Parse("79228162514264337593543950336"), 0), + new FbDecFloat(1, -29), + new FbDecFloat(BigInteger.Parse("10000000000000000000000000000001"), -31), + new FbDecFloat(1, int.MaxValue), + new FbDecFloat(1, int.MinValue), + FbDecFloat.PositiveNaN, + FbDecFloat.PositiveSignalingNaN, + FbDecFloat.NegativeInfinity, + }; + [TestCaseSource(nameof(ToDecimalThrowsSource))] + public void ConvertsToDecimalThrowsWhenNotExact(FbDecFloat value) + { + Assert.Throws(() => _ = (decimal)value); + } + + static readonly object[] ToDoubleSource = new object[] + { + new object[] { new FbDecFloat(66, -1), 6.6 }, + new object[] { new FbDecFloat(BigInteger.Parse("30000000000000004"), -17), 0.1 + 0.2 }, + new object[] { new FbDecFloat(1, 400), double.PositiveInfinity }, + new object[] { new FbDecFloat(-1, 400), double.NegativeInfinity }, + new object[] { new FbDecFloat(1, -400), 0d }, + new object[] { FbDecFloat.PositiveInfinity, double.PositiveInfinity }, + new object[] { FbDecFloat.NegativeInfinity, double.NegativeInfinity }, + new object[] { FbDecFloat.PositiveNaN, double.NaN }, + new object[] { FbDecFloat.NegativeSignalingNaN, double.NaN }, + }; + [TestCaseSource(nameof(ToDoubleSource))] + public void ConvertsToNearestDouble(FbDecFloat value, double expected) + { + Assert.AreEqual(expected, (double)value); + Assert.AreEqual((float)expected, (float)value); + } + + [Test] + public void ConvertsNegativeZeroToFloatingPoint() + { + Assert.IsTrue(double.IsNegative((double)FbDecFloat.NegativeZero)); + Assert.IsTrue(float.IsNegative((float)FbDecFloat.NegativeZero)); + } + + [TestCase("2", 1, 20)] + [TestCase("25", -1, 2)] + [TestCase("35", -1, 4)] + [TestCase("-25", -1, -2)] + [TestCase("251", -2, 3)] + [TestCase("5", -1, 0)] + [TestCase("15", -1, 2)] + [TestCase("1", int.MinValue, 0)] + public void RoundsToIntegerToEven(string coefficient, int exponent, long expected) + { + var value = new FbDecFloat(BigInteger.Parse(coefficient), exponent); + Assert.AreEqual(new BigInteger(expected), value.ToIntegerRoundingToEven()); + } } diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 44b79a0f1..70e1827c0 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -189,6 +189,7 @@ public byte GetByte() return _value switch { BigInteger bi => (byte)bi, + FbDecFloat df => (byte)df.ToIntegerRoundingToEven(), _ => Convert.ToByte(_value, CultureInfo.InvariantCulture), }; } @@ -198,6 +199,7 @@ public short GetInt16() return _value switch { BigInteger bi => (short)bi, + FbDecFloat df => (short)df.ToIntegerRoundingToEven(), _ => Convert.ToInt16(_value, CultureInfo.InvariantCulture), }; } @@ -207,6 +209,7 @@ public int GetInt32() return _value switch { BigInteger bi => (int)bi, + FbDecFloat df => (int)df.ToIntegerRoundingToEven(), _ => Convert.ToInt32(_value, CultureInfo.InvariantCulture), }; } @@ -216,18 +219,27 @@ public long GetInt64() return _value switch { BigInteger bi => (long)bi, + FbDecFloat df => (long)df.ToIntegerRoundingToEven(), _ => Convert.ToInt64(_value, CultureInfo.InvariantCulture), }; } public decimal GetDecimal() { - return Convert.ToDecimal(_value, CultureInfo.InvariantCulture); + return _value switch + { + FbDecFloat df => (decimal)df, + _ => Convert.ToDecimal(_value, CultureInfo.InvariantCulture), + }; } public float GetFloat() { - return Convert.ToSingle(_value, CultureInfo.InvariantCulture); + return _value switch + { + FbDecFloat df => (float)df, + _ => Convert.ToSingle(_value, CultureInfo.InvariantCulture), + }; } public Guid GetGuid() @@ -242,7 +254,11 @@ public Guid GetGuid() public double GetDouble() { - return Convert.ToDouble(_value, CultureInfo.InvariantCulture); + return _value switch + { + FbDecFloat df => (double)df, + _ => Convert.ToDouble(_value, CultureInfo.InvariantCulture), + }; } public DateTime GetDateTime() @@ -262,7 +278,23 @@ public TimeSpan GetTimeSpan() public FbDecFloat GetDecFloat() { - return (FbDecFloat)_value; + return _value switch + { + FbDecFloat df => df, + decimal d => (FbDecFloat)d, + double d => (FbDecFloat)d, + float f => (FbDecFloat)f, + byte b => b, + sbyte sb => sb, + short s => s, + ushort us => us, + int i => i, + uint ui => ui, + long l => l, + ulong ul => ul, + BigInteger bi => new FbDecFloat(bi), + _ => (FbDecFloat)_value, + }; } public BigInteger GetInt128() diff --git a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs index 7c2c22c50..c901fe992 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs @@ -86,6 +86,99 @@ public static explicit operator FbDecFloat(decimal value) return new FbDecFloat(DecimalType.Finite, negative, negative ? -coefficient : coefficient, -scale); } + // Exact (the scale is kept when possible, e.g. 110E-2 -> 1.10m). + // Throws OverflowException when the value cannot be represented exactly as decimal, including NaN and infinities. + public static explicit operator decimal(FbDecFloat value) + { + if (value.Type != DecimalType.Finite) + { + throw value.CannotRepresent(nameof(Decimal)); + } + var coefficient = BigInteger.Abs(value.Coefficient); + var exponent = value.Exponent; + if (exponent > 0) + { + if (!coefficient.IsZero) + { + if (exponent > 28) + { + throw value.CannotRepresent(nameof(Decimal)); + } + coefficient *= BigInteger.Pow(10, exponent); + } + exponent = 0; + } + // only trailing zeros can be dropped + while (exponent < 0 && (exponent < -28 || coefficient > MaxDecimalCoefficient)) + { + var quotient = BigInteger.DivRem(coefficient, 10, out var remainder); + if (!remainder.IsZero) + { + break; + } + coefficient = quotient; + exponent++; + } + if (exponent < -28 || coefficient > MaxDecimalCoefficient) + { + throw value.CannotRepresent(nameof(Decimal)); + } + var bits = (UInt128)coefficient; + return new decimal((int)(uint)bits, (int)(uint)(bits >> 32), (int)(uint)(bits >> 64), value.Negative, (byte)-exponent); + } + // Nearest double (overflows to infinity, underflows to zero); NaN and infinities are mapped. + public static explicit operator double(FbDecFloat value) => value.Type switch + { + DecimalType.Infinity => value.Negative ? double.NegativeInfinity : double.PositiveInfinity, + DecimalType.NaN or DecimalType.SignalingNaN => double.NaN, + _ => value.Negative && value.Coefficient.IsZero ? -0d : double.Parse(value.ToInvariantScientificString(), NumberStyles.Float, CultureInfo.InvariantCulture), + }; + // Nearest float (overflows to infinity, underflows to zero); NaN and infinities are mapped. + public static explicit operator float(FbDecFloat value) => value.Type switch + { + DecimalType.Infinity => value.Negative ? float.NegativeInfinity : float.PositiveInfinity, + DecimalType.NaN or DecimalType.SignalingNaN => float.NaN, + _ => value.Negative && value.Coefficient.IsZero ? -0f : float.Parse(value.ToInvariantScientificString(), NumberStyles.Float, CultureInfo.InvariantCulture), + }; + + static readonly BigInteger MaxDecimalCoefficient = (BigInteger)decimal.MaxValue; + + string ToInvariantScientificString() => $"{Coefficient.ToString(CultureInfo.InvariantCulture)}E{Exponent.ToString(CultureInfo.InvariantCulture)}"; + + OverflowException CannotRepresent(string type) => new OverflowException($"Value {this} cannot be represented exactly as {type}."); + + // Nearest integer, ties to even (as Convert.ToInt64(decimal) does). + // Throws OverflowException for NaN, infinities and values far beyond the 64-bit range. + internal BigInteger ToIntegerRoundingToEven() + { + if (Type != DecimalType.Finite) + { + throw CannotRepresent(nameof(BigInteger)); + } + if (Exponent >= 0) + { + if (Exponent > 40 && !Coefficient.IsZero) + { + throw CannotRepresent(nameof(BigInteger)); + } + return Coefficient * BigInteger.Pow(10, Exponent); + } + var digitsToDrop = -(long)Exponent; + if (digitsToDrop > BigInteger.Abs(Coefficient).GetBitLength()) + { + // |value| < 0.5 + return BigInteger.Zero; + } + var divisor = BigInteger.Pow(10, (int)digitsToDrop); + var quotient = BigInteger.DivRem(Coefficient, divisor, out var remainder); + var twice = BigInteger.Abs(remainder) * 2; + if (twice > divisor || (twice == divisor && !quotient.IsEven)) + { + quotient += Coefficient.Sign; + } + return quotient; + } + public override string ToString() { if (this == NegativeZero) From 683cc93d9804ab65a873b39ec65abffb649f586f Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 11:16:41 -0300 Subject: [PATCH 5/5] Document DECFLOAT conversions from and to other .NET types --- docs/decfloat.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/decfloat.md b/docs/decfloat.md index 3bb60e305..4aacbce97 100644 --- a/docs/decfloat.md +++ b/docs/decfloat.md @@ -2,6 +2,25 @@ `DECFLOAT(16)` and `DECFLOAT(34)` from Firebird 4 is handled by `FbDecFloat` type. `FbDecFloat` can be used as parameter value for `FbParameter`. +### Values are never rounded silently + +`DECFLOAT(16)` holds 16 significant digits and `DECFLOAT(34)` holds 34. A value that cannot be represented exactly in the precision or exponent range of the target (for example a 17 digit value passed to a `DECFLOAT(16)` parameter) throws `OverflowException`. Values that only need trailing zeros removed or the exponent adjusted (e.g. `1E384` in `DECFLOAT(16)`) are accepted. + +### Other .NET types + +Parameters: besides `FbDecFloat`, a `DECFLOAT` parameter accepts `decimal`, `double`, `float`, the integral types and `BigInteger`: + +* `decimal` is converted exactly, including its scale (`1.10m` stays `1.10`). +* `double` and `float` are converted to the shortest decimal that converts back to the same binary value (`0.1` is `0.1`, `0.1 + 0.2` is `0.30000000000000004`), which may have up to 17 significant digits. + +Reading a `DECFLOAT` column: + +* `GetDecimal` is exact; it throws `OverflowException` when the value cannot be represented exactly as `decimal` (too large, too many digits, NaN or infinity). +* `GetDouble` and `GetFloat` return the nearest value (NaN and infinities are mapped). +* `GetInt16`, `GetInt32`, `GetInt64` and `GetByte` round to the nearest integer, ties to even, and throw `OverflowException` when out of range. + +`FbDecFloat` has explicit conversions from and to `decimal`, `double` and `float`, with the same rules. + ### Examples Examples can be found in [`FbDecFloat16SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs) and [`FbDecFloat34SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs).