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/3] 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/3] 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/3] 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); } }