From c1cf2d2d291c86ac8e2b0225711a213c14190606 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:39:52 -0300 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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). From 24faf8205461ebb3c0c60f34f46a867ab9ada86e Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 10:31:53 -0300 Subject: [PATCH 06/11] Add net11.0 target framework - Provider: add net11.0 to TargetFrameworks. No Microsoft.Extensions.Logging.Abstractions package reference is needed for net11.0: the assembly ships in the .NET 11 shared framework, and NuGet prunes the reference (the nuspec net11.0 group is empty). - Tests: multi-target FirebirdSql.Data.FirebirdClient.Tests to net10.0;net11.0 so TFM-specific code paths are exercised on both. - tests.ps1: copy the Firebird binaries into, and run the FirebirdClient suites from, every provider test TFM output. - CI: install the .NET 11 SDK (preview quality until 11.0 GA). --- .github/workflows/ci.yml | 6 +++++ ...rebirdSql.Data.FirebirdClient.Tests.csproj | 2 +- .../FirebirdSql.Data.FirebirdClient.csproj | 2 +- tests.ps1 | 23 +++++++++++-------- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34f49d975..627da8c83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: with: dotnet-version: 10.0.x + - name: .NET 11.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + - name: Build run: | try { diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FirebirdSql.Data.FirebirdClient.Tests.csproj b/src/FirebirdSql.Data.FirebirdClient.Tests/FirebirdSql.Data.FirebirdClient.Tests.csproj index ff0b4ae13..2f7921b1d 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FirebirdSql.Data.FirebirdClient.Tests.csproj +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FirebirdSql.Data.FirebirdClient.Tests.csproj @@ -1,6 +1,6 @@  - net10.0 + net10.0;net11.0 false false true diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj b/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj index a040e0613..4e5914a57 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj @@ -1,6 +1,6 @@  - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0;net11.0 FirebirdSql.Data.FirebirdClient FirebirdSql.Data true diff --git a/tests.ps1 b/tests.ps1 index 6af9ac102..93b6d8a4b 100644 --- a/tests.ps1 +++ b/tests.ps1 @@ -29,8 +29,8 @@ $FirebirdConfiguration = @{ } $frameworkVersion = 'net10.0' +$providerTestsFrameworkVersions = @('net10.0', 'net11.0') $testsBaseDir = "$baseDir\src\FirebirdSql.Data.FirebirdClient.Tests" -$testsProviderDir = "$testsBaseDir\bin\$Configuration\$frameworkVersion" $firebirdProcess = $null @@ -59,7 +59,9 @@ function Prepare() { echo "Extracting $fbDownloadName" 7z x -bsp0 -bso0 $fbDownloadName rm $fbDownloadName - cp -Recurse -Force .\* $testsProviderDir + foreach ($providerTestsFrameworkVersion in $providerTestsFrameworkVersions) { + cp -Recurse -Force .\* "$testsBaseDir\bin\$Configuration\$providerTestsFrameworkVersion" + } ni firebird.log -ItemType File | Out-Null @@ -120,13 +122,16 @@ function Tests-FirebirdClient-Embedded() { Tests-FirebirdClient 'Embedded' $False 'Disabled' } function Tests-FirebirdClient($serverType, $compression, $wireCrypt) { - pushd $testsProviderDir - try { - .\FirebirdSql.Data.FirebirdClient.Tests.exe --labels=All "--where=(ServerType==$serverType && Compression==$compression && WireCrypt==$wireCrypt) || Category==NoServer" - Check-ExitCode - } - finally { - popd + foreach ($providerTestsFrameworkVersion in $providerTestsFrameworkVersions) { + echo "=== FirebirdClient tests ($providerTestsFrameworkVersion) ===" + pushd "$testsBaseDir\bin\$Configuration\$providerTestsFrameworkVersion" + try { + .\FirebirdSql.Data.FirebirdClient.Tests.exe --labels=All "--where=(ServerType==$serverType && Compression==$compression && WireCrypt==$wireCrypt) || Category==NoServer" + Check-ExitCode + } + finally { + popd + } } } From fdd5b069faef870fc8252a55f610538b4af2cc4b Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 11:07:42 -0300 Subject: [PATCH 07/11] Support .NET 11 Decimal128/Decimal64/Decimal32 for DECFLOAT (net11.0) .NET 11 introduces the IEEE 754 decimal floating-point types Decimal32, Decimal64 and Decimal128. Their decimal interchange encoding (EncodeDecimal/DecodeDecimal, densely packed decimal) is the encoding of Firebird's DECFLOAT(16)/DECFLOAT(34): the provider's DECFLOAT codec and .NET agree bit for bit (asserted by a seeded test over random values and all special values), so the conversions are exact, including the exponent (1.10 stays 1.10), negative zero, infinities and NaNs, with no text round trip. On net11.0 only: - FbDecFloat: explicit conversions from Decimal32/Decimal64/Decimal128 (exact) and to Decimal64/Decimal128 (exact, OverflowException when the value does not fit). - Parameters: Decimal128 values infer FbDbType.Dec34, Decimal64 and Decimal32 infer FbDbType.Dec16. They can be passed to any numeric parameter; a value that does not fit the target exactly (e.g. a 34-digit Decimal128 into DECFLOAT(16)) throws OverflowException instead of being rounded. - Reader: GetFieldValue() and GetFieldValue() for DECFLOAT and the other numeric types (exact, OverflowException when the value does not fit). GetValue()/GetFieldType() still return FbDecFloat for DECFLOAT columns. --- .../FbDecFloatIeee754Tests.cs | 157 +++++++++++ .../FbDecimalIeee754SupportTests.cs | 263 ++++++++++++++++++ .../Common/DbValue.cs | 36 ++- .../Common/TypeHelper.cs | 10 + .../FirebirdClient/FbDataReader.cs | 20 ++ .../Types/FbDecFloat.cs | 44 +++ 6 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatIeee754Tests.cs create mode 100644 src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatIeee754Tests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatIeee754Tests.cs new file mode 100644 index 000000000..2bf996108 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloatIeee754Tests.cs @@ -0,0 +1,157 @@ +/* + * 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 + +#if NET11_0_OR_GREATER + +using System; +using System.Buffers.Binary; +using System.Globalization; +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 FbDecFloatIeee754Tests +{ + static BigInteger RandomCoefficient(Random random, int maxDigits) + { + var digits = random.Next(0, maxDigits + 1); + if (digits == 0) + return BigInteger.Zero; + var chars = new char[digits]; + for (var i = 0; i < digits; i++) + chars[i] = (char)('0' + random.Next(10)); + var result = BigInteger.Parse(new string(chars), CultureInfo.InvariantCulture); + return random.Next(2) == 0 ? result : -result; + } + + // The provider's DECFLOAT codec and .NET's IEEE 754 decimal interchange encoding (EncodeDecimal/DecodeDecimal) + // must agree bit for bit; the Decimal64/Decimal128 support relies on it. + [Test] + public void DecFloat34CodecMatchesDecimal128Encoding() + { + var random = new Random(20261107); + for (var i = 0; i < 20_000; i++) + { + var value = new FbDecFloat(RandomCoefficient(random, 34), random.Next(-6176, 6111 + 1)); + var bits = BinaryPrimitives.ReadUInt128LittleEndian(DecimalCodec.DecFloat34.EncodeDecimal(value)); + var d = (Decimal128)value; + Assert.AreEqual(bits, Decimal128.EncodeDecimal(d), value.ToString()); + var expected = Decimal128.Parse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture); + Assert.IsTrue(d.Equals(expected) && Decimal128.HaveSameQuantum(d, expected), value.ToString()); + var back = (FbDecFloat)d; + Assert.AreEqual(value.Coefficient, back.Coefficient, value.ToString()); + Assert.AreEqual(value.Exponent, back.Exponent, value.ToString()); + } + } + + [Test] + public void DecFloat16CodecMatchesDecimal64Encoding() + { + var random = new Random(20261107); + for (var i = 0; i < 20_000; i++) + { + var value = new FbDecFloat(RandomCoefficient(random, 16), random.Next(-398, 369 + 1)); + var bits = BinaryPrimitives.ReadUInt64LittleEndian(DecimalCodec.DecFloat16.EncodeDecimal(value)); + var d = (Decimal64)value; + Assert.AreEqual(bits, Decimal64.EncodeDecimal(d), value.ToString()); + var expected = Decimal64.Parse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture); + Assert.IsTrue(d.Equals(expected) && Decimal64.HaveSameQuantum(d, expected), value.ToString()); + var back = (FbDecFloat)d; + Assert.AreEqual(value.Coefficient, back.Coefficient, value.ToString()); + Assert.AreEqual(value.Exponent, back.Exponent, value.ToString()); + } + } + + static readonly object[] SpecialValues = new object[] + { + FbDecFloat.NegativeZero, + FbDecFloat.PositiveInfinity, + FbDecFloat.NegativeInfinity, + FbDecFloat.PositiveNaN, + FbDecFloat.NegativeNaN, + FbDecFloat.PositiveSignalingNaN, + FbDecFloat.NegativeSignalingNaN, + }; + [TestCaseSource(nameof(SpecialValues))] + public void ConvertsSpecialValuesBitExactly(FbDecFloat value) + { + var bits128 = BinaryPrimitives.ReadUInt128LittleEndian(DecimalCodec.DecFloat34.EncodeDecimal(value)); + Assert.AreEqual(bits128, Decimal128.EncodeDecimal((Decimal128)value)); + Assert.AreEqual(value, (FbDecFloat)(Decimal128)value); + var bits64 = BinaryPrimitives.ReadUInt64LittleEndian(DecimalCodec.DecFloat16.EncodeDecimal(value)); + Assert.AreEqual(bits64, Decimal64.EncodeDecimal((Decimal64)value)); + Assert.AreEqual(value, (FbDecFloat)(Decimal64)value); + } + + [Test] + public void ConvertsSpecialDecimal128Values() + { + Assert.AreEqual(FbDecFloat.NegativeZero, (FbDecFloat)Decimal128.NegativeZero); + Assert.AreEqual(FbDecFloat.PositiveInfinity, (FbDecFloat)Decimal128.PositiveInfinity); + Assert.AreEqual(FbDecFloat.NegativeInfinity, (FbDecFloat)Decimal128.NegativeInfinity); + Assert.IsTrue(Decimal128.IsNaN((Decimal128)FbDecFloat.PositiveNaN)); + Assert.IsTrue(Decimal128.IsNegative((Decimal128)FbDecFloat.NegativeZero)); + } + + [Test] + public void ConvertsDecimal32ExactlyThroughDecimal64() + { + var value = Decimal32.Parse("1.10", CultureInfo.InvariantCulture); + var result = (FbDecFloat)value; + Assert.AreEqual(new BigInteger(110), result.Coefficient); + Assert.AreEqual(-2, result.Exponent); + } + + static readonly object[] NotRepresentableAsDecimal64 = new object[] + { + new FbDecFloat(BigInteger.Parse("12345678901234567"), 0), + new FbDecFloat(1, 385), + new FbDecFloat(1, -399), + }; + [TestCaseSource(nameof(NotRepresentableAsDecimal64))] + public void ConvertingToDecimal64ThrowsInsteadOfRounding(FbDecFloat value) + { + Assert.Throws(() => _ = (Decimal64)value); + } + + static readonly object[] NotRepresentableAsDecimal128 = new object[] + { + new FbDecFloat(BigInteger.Parse("12345678901234567890123456789012345"), 0), + new FbDecFloat(1, 6145), + new FbDecFloat(1, -6177), + }; + [TestCaseSource(nameof(NotRepresentableAsDecimal128))] + public void ConvertingToDecimal128ThrowsInsteadOfRounding(FbDecFloat value) + { + Assert.Throws(() => _ = (Decimal128)value); + } + + [Test] + public void ConvertsRepresentableValueToDecimal64AfterNormalization() + { + // 17 digits, but the last one is a trailing zero + var value = new FbDecFloat(BigInteger.Parse("12345678901234560"), 0); + Assert.AreEqual(Decimal64.Parse("12345678901234560", CultureInfo.InvariantCulture), (Decimal64)value); + } +} + +#endif diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs new file mode 100644 index 000000000..ada460b3b --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs @@ -0,0 +1,263 @@ +/* + * 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 + +#if NET11_0_OR_GREATER + +using System; +using System.Globalization; +using System.Numerics; +using System.Threading.Tasks; +using FirebirdSql.Data.TestsBase; +using FirebirdSql.Data.Types; +using NUnit.Framework; + +namespace FirebirdSql.Data.FirebirdClient.Tests; + +[TestFixtureSource(typeof(FbServerTypeTestFixtureSource), nameof(FbServerTypeTestFixtureSource.Default))] +[TestFixtureSource(typeof(FbServerTypeTestFixtureSource), nameof(FbServerTypeTestFixtureSource.Embedded))] +public class FbDecimalIeee754SupportTests : FbTestsBase +{ + public FbDecimalIeee754SupportTests(FbServerType serverType, bool compression, FbWireCrypt wireCrypt) + : base(serverType, compression, wireCrypt, false) + { } + + [SetUp] + public override async Task SetUp() + { + await base.SetUp(); + + if (!EnsureServerVersionAtLeast(new Version(4, 0, 0, 0))) + return; + } + + static Decimal128 D128(string s) => Decimal128.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture); + static Decimal64 D64(string s) => Decimal64.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture); + + static void AssertSameValueAndExponent(Decimal128 expected, Decimal128 actual) + { + Assert.AreEqual(Decimal128.EncodeDecimal(expected), Decimal128.EncodeDecimal(actual), $"expected {expected.ToString(CultureInfo.InvariantCulture)}, was {actual.ToString(CultureInfo.InvariantCulture)}"); + } + + static void AssertSameValueAndExponent(Decimal64 expected, Decimal64 actual) + { + Assert.AreEqual(Decimal64.EncodeDecimal(expected), Decimal64.EncodeDecimal(actual), $"expected {expected.ToString(CultureInfo.InvariantCulture)}, was {actual.ToString(CultureInfo.InvariantCulture)}"); + } + + async Task SelectAsync(string sql) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = sql; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + return await reader.GetFieldValueAsync(0); + } + } + } + + async Task RoundTripAsync(string castAs, object value) + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"select cast(@value as {castAs}) from rdb$database"; + cmd.Parameters.AddWithValue("value", value); + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + return reader.GetFieldValue(0); + } + } + } + + [TestCase("0")] + [TestCase("1.10")] + [TestCase("-123.456")] + [TestCase("1234567890123456789012345678901234")] + [TestCase("1.234567890123456789012345678901234E-100")] + [TestCase("9.999999999999999999999999999999999E+6144")] + [TestCase("1E-6176")] + [TestCase("0.00000")] + [TestCase("-0")] + [TestCase("Infinity")] + [TestCase("-Infinity")] + public async Task ReadsDecFloat34AsDecimal128(string literal) + { + var result = await SelectAsync($"select cast('{literal}' as decfloat(34)) from rdb$database"); + AssertSameValueAndExponent(D128(literal), result); + } + + [Test] + public async Task ReadsDecFloat34NaNAsDecimal128() + { + Assert.IsTrue(Decimal128.IsNaN(await SelectAsync("select cast('NaN' as decfloat(34)) from rdb$database"))); + Assert.IsTrue(Decimal128.IsNaN(await SelectAsync("select cast('-sNaN' as decfloat(34)) from rdb$database"))); + } + + [TestCase("1.10")] + [TestCase("1234567890123456")] + [TestCase("9.999999999999999E+384")] + [TestCase("1E-398")] + [TestCase("-0")] + [TestCase("-Infinity")] + public async Task ReadsDecFloat16AsDecimal64AndDecimal128(string literal) + { + AssertSameValueAndExponent(D64(literal), await SelectAsync($"select cast('{literal}' as decfloat(16)) from rdb$database")); + AssertSameValueAndExponent(D128(literal), await SelectAsync($"select cast('{literal}' as decfloat(16)) from rdb$database")); + } + + [TestCase("1.5")] + [TestCase("1234567890123456")] + [TestCase("12345678901234560")] + public async Task ReadsDecFloat34AsDecimal64WhenExact(string literal) + { + var result = await SelectAsync($"select cast('{literal}' as decfloat(34)) from rdb$database"); + Assert.AreEqual(D64(literal), result); + } + + [TestCase("12345678901234567")] + [TestCase("1E+385")] + [TestCase("1E-399")] + public void ReadsDecFloat34AsDecimal64ThrowsWhenNotExact(string literal) + { + Assert.ThrowsAsync(() => SelectAsync($"select cast('{literal}' as decfloat(34)) from rdb$database")); + } + + [TestCase("cast(1.10 as numeric(18, 2))", "1.1")] + [TestCase("cast(-123456789012.345678 as numeric(18, 6))", "-123456789012.345678")] + [TestCase("cast(42 as integer)", "42")] + [TestCase("cast(-9223372036854775808 as bigint)", "-9223372036854775808")] + [TestCase("cast(0.1 as double precision)", "0.1")] + public async Task ReadsOtherNumericTypesAsDecimal128(string expression, string expected) + { + Assert.AreEqual(D128(expected), await SelectAsync($"select {expression} from rdb$database")); + } + + [Test] + public void ReadsInt128BeyondDecimal128Throws() + { + Assert.ThrowsAsync(() => SelectAsync("select cast(170141183460469231731687303715884105727 as int128) from rdb$database")); + } + + [TestCase("1.10")] + [TestCase("-123.456")] + [TestCase("1234567890123456789012345678901234")] + [TestCase("9.999999999999999999999999999999999E+6144")] + [TestCase("1E-6176")] + [TestCase("0.00000")] + [TestCase("-0")] + [TestCase("Infinity")] + [TestCase("-Infinity")] + public async Task PassesDecimal128ToDecFloat34(string literal) + { + var value = D128(literal); + Assert.AreEqual(FbDbType.Dec34, new FbParameter("value", value).FbDbType); + AssertSameValueAndExponent(value, await RoundTripAsync("decfloat(34)", value)); + } + + [Test] + public async Task PassesDecimal128NaNToDecFloat34() + { + Assert.IsTrue(Decimal128.IsNaN(await RoundTripAsync("decfloat(34)", Decimal128.NaN))); + } + + [TestCase("1.10")] + [TestCase("1234567890123456")] + [TestCase("-9.999999999999999E+384")] + [TestCase("1E-398")] + [TestCase("-0")] + public async Task PassesDecimal64ToDecFloat16(string literal) + { + var value = D64(literal); + Assert.AreEqual(FbDbType.Dec16, new FbParameter("value", value).FbDbType); + AssertSameValueAndExponent(value, await RoundTripAsync("decfloat(16)", value)); + } + + [Test] + public async Task PassesDecimal32ToDecFloat16() + { + var value = Decimal32.Parse("1.10", CultureInfo.InvariantCulture); + Assert.AreEqual(FbDbType.Dec16, new FbParameter("value", value).FbDbType); + AssertSameValueAndExponent(D64("1.10"), await RoundTripAsync("decfloat(16)", value)); + } + + [TestCase("1.5")] + [TestCase("1234567890123456")] + [TestCase("12345678901234560")] + public async Task PassesDecimal128ToDecFloat16WhenExact(string literal) + { + var result = await RoundTripAsync("decfloat(16)", D128(literal)); + Assert.AreEqual(D128(literal), result); + } + + [TestCase("12345678901234567")] + [TestCase("1234567890123456789012345678901234")] + [TestCase("1E+385")] + [TestCase("1E-399")] + public async Task PassesDecimal128ToDecFloat16ThrowsWhenNotExact(string literal) + { + Assert.ThrowsAsync(() => RoundTripAsync("decfloat(16)", D128(literal))); + Assert.AreEqual(D128("1"), await SelectAsync("select cast(1 as decfloat(16)) from rdb$database"), "connection must remain usable"); + } + + [TestCase("1.2345", "numeric(18, 4)", "1.2345")] + [TestCase("42", "integer", "42")] + [TestCase("0.5", "double precision", "0.5")] + public async Task PassesDecimal128ToOtherNumericTypes(string literal, string type, string expected) + { + Assert.AreEqual(D128(expected), await RoundTripAsync(type, D128(literal))); + } + + [Test] + public async Task InsertsAndReadsBackDecimal128() + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "recreate table decimal128_test (id integer, d16 decfloat(16), d34 decfloat(34))"; + await cmd.ExecuteNonQueryAsync(); + } + var d34 = D128("1234567890123456789012345678.901234"); + var d16 = D64("-1.100"); + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "insert into decimal128_test (id, d16, d34) values (1, @d16, @d34)"; + cmd.Parameters.AddWithValue("d16", d16); + cmd.Parameters.AddWithValue("d34", d34); + Assert.AreEqual(1, await cmd.ExecuteNonQueryAsync()); + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select d16, d34 from decimal128_test where id = 1"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.IsTrue(await reader.ReadAsync()); + AssertSameValueAndExponent(d16, reader.GetFieldValue(0)); + AssertSameValueAndExponent(d34, reader.GetFieldValue(1)); + // the default CLR type is unchanged + Assert.AreEqual(typeof(FbDecFloat), reader.GetFieldType(1)); + Assert.IsInstanceOf(reader.GetValue(1)); + } + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "drop table decimal128_test"; + await cmd.ExecuteNonQueryAsync(); + } + } +} + +#endif diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 70e1827c0..dd464c49d 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -40,14 +40,30 @@ public DbField Field public DbValue(DbField field, object value) { _field = field; - _value = value ?? DBNull.Value; + _value = Normalize(value) ?? DBNull.Value; } public DbValue(StatementBase statement, DbField field, object value) { _statement = statement; _field = field; - _value = value ?? DBNull.Value; + _value = Normalize(value) ?? DBNull.Value; + } + + // IEEE 754 decimal values are kept as FbDecFloat (exact), so every conversion that handles FbDecFloat handles them too. + static object Normalize(object value) + { +#if NET11_0_OR_GREATER + return value switch + { + Decimal128 d => (FbDecFloat)d, + Decimal64 d => (FbDecFloat)d, + Decimal32 d => (FbDecFloat)d, + _ => value, + }; +#else + return value; +#endif } public bool IsDBNull() @@ -144,7 +160,7 @@ public async ValueTask GetValueAsync(CancellationToken cancellationToken public void SetValue(object value) { - _value = value; + _value = Normalize(value); } public string GetString() @@ -276,6 +292,20 @@ public TimeSpan GetTimeSpan() return (TimeSpan)_value; } +#if NET11_0_OR_GREATER + // exact; OverflowException when the value cannot be represented exactly + public Decimal128 GetDecimal128() + { + return (Decimal128)GetDecFloat(); + } + + // exact; OverflowException when the value cannot be represented exactly + public Decimal64 GetDecimal64() + { + return (Decimal64)GetDecFloat(); + } +#endif + public FbDecFloat GetDecFloat() { return _value switch diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs b/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs index f8417b4de..9f08b961e 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs @@ -465,6 +465,16 @@ public static FbDbType GetFbDataTypeFromType(Type type) { return FbDbType.Dec34; } +#if NET11_0_OR_GREATER + else if (type == typeof(System.Numerics.Decimal128)) + { + return FbDbType.Dec34; + } + else if (type == typeof(System.Numerics.Decimal64) || type == typeof(System.Numerics.Decimal32)) + { + return FbDbType.Dec16; + } +#endif else if (type == typeof(System.Numerics.BigInteger)) { return FbDbType.Int128; diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs index 0cf1d40c4..45737535a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs @@ -663,6 +663,16 @@ public override T GetFieldValue(int i) { return (T)(object)_row[i].GetDecFloat(); } +#if NET11_0_OR_GREATER + else if (type == typeof(Decimal128)) + { + return (T)(object)_row[i].GetDecimal128(); + } + else if (type == typeof(Decimal64)) + { + return (T)(object)_row[i].GetDecimal64(); + } +#endif else if (type == typeof(BigInteger)) { return (T)(object)_row[i].GetInt128(); @@ -764,6 +774,16 @@ public override async Task GetFieldValueAsync(int i, CancellationToken can { return (T)(object)_row[i].GetDecFloat(); } +#if NET11_0_OR_GREATER + else if (type == typeof(Decimal128)) + { + return (T)(object)_row[i].GetDecimal128(); + } + else if (type == typeof(Decimal64)) + { + return (T)(object)_row[i].GetDecimal64(); + } +#endif else if (type == typeof(BigInteger)) { return (T)(object)_row[i].GetInt128(); diff --git a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs index c901fe992..242f88dbd 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs @@ -16,6 +16,7 @@ //$Authors = Jiri Cincura (jiri@cincura.net) using System; +using System.Buffers.Binary; using System.Globalization; using System.Numerics; using System.Runtime.InteropServices; @@ -147,6 +148,49 @@ public static explicit operator decimal(FbDecFloat value) OverflowException CannotRepresent(string type) => new OverflowException($"Value {this} cannot be represented exactly as {type}."); +#if NET11_0_OR_GREATER + // The IEEE 754 decimal interchange encoding of Decimal64/Decimal128 (EncodeDecimal/DecodeDecimal, densely packed decimal) + // is the encoding of DECFLOAT(16)/DECFLOAT(34), so the conversions go through the DECFLOAT codec and are exact, + // including the exponent (1.10 stays 1.10), negative zero, infinities and NaNs. + + public static explicit operator FbDecFloat(Decimal128 value) => DecimalCodec.DecFloat34.ParseBytes(ToHostBytes(Decimal128.EncodeDecimal(value))); + public static explicit operator FbDecFloat(Decimal64 value) => DecimalCodec.DecFloat16.ParseBytes(ToHostBytes(Decimal64.EncodeDecimal(value))); + public static explicit operator FbDecFloat(Decimal32 value) => (FbDecFloat)(Decimal64)value; + + // Exact; throws OverflowException when the value has more than 34 significant digits or its exponent is out of range. + public static explicit operator Decimal128(FbDecFloat value) + { + var bytes = DecimalCodec.DecFloat34.EncodeDecimal(value); + return Decimal128.DecodeDecimal(BitConverter.IsLittleEndian ? BinaryPrimitives.ReadUInt128LittleEndian(bytes) : BinaryPrimitives.ReadUInt128BigEndian(bytes)); + } + // Exact; throws OverflowException when the value has more than 16 significant digits or its exponent is out of range. + public static explicit operator Decimal64(FbDecFloat value) + { + var bytes = DecimalCodec.DecFloat16.EncodeDecimal(value); + return Decimal64.DecodeDecimal(BitConverter.IsLittleEndian ? BinaryPrimitives.ReadUInt64LittleEndian(bytes) : BinaryPrimitives.ReadUInt64BigEndian(bytes)); + } + + // the codec works with host byte order + static byte[] ToHostBytes(UInt128 bits) + { + var bytes = new byte[16]; + if (BitConverter.IsLittleEndian) + BinaryPrimitives.WriteUInt128LittleEndian(bytes, bits); + else + BinaryPrimitives.WriteUInt128BigEndian(bytes, bits); + return bytes; + } + static byte[] ToHostBytes(ulong bits) + { + var bytes = new byte[8]; + if (BitConverter.IsLittleEndian) + BinaryPrimitives.WriteUInt64LittleEndian(bytes, bits); + else + BinaryPrimitives.WriteUInt64BigEndian(bytes, bits); + return bytes; + } +#endif + // 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() From 73685e5dec08fadc96a1f9dbad3b29d374f8b848 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 11:16:58 -0300 Subject: [PATCH 08/11] Document Decimal64/Decimal128 support --- docs/decfloat.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/decfloat.md b/docs/decfloat.md index 4aacbce97..e93641c76 100644 --- a/docs/decfloat.md +++ b/docs/decfloat.md @@ -21,6 +21,21 @@ Reading a `DECFLOAT` column: `FbDecFloat` has explicit conversions from and to `decimal`, `double` and `float`, with the same rules. +### .NET 11 `Decimal64` and `Decimal128` + +On .NET 11 or later, the IEEE 754 decimal types `System.Numerics.Decimal64` and `System.Numerics.Decimal128` match `DECFLOAT(16)` and `DECFLOAT(34)`. Conversions are exact, including the exponent (`1.10` stays `1.10`), negative zero, infinities and NaNs. + +* Parameters: a `Decimal128` value is a `FbDbType.Dec34` parameter, a `Decimal64` or `Decimal32` value a `FbDbType.Dec16` parameter. They can also be passed to other numeric parameters. A `Decimal128` value passed to a `DECFLOAT(16)` parameter works when it fits exactly, otherwise it throws `OverflowException`. +* Reader: `GetFieldValue()` and `GetFieldValue()` read `DECFLOAT` and the other numeric types; they throw `OverflowException` when the value does not fit exactly (e.g. a 34 digit `DECFLOAT(34)` value read as `Decimal64`). +* `FbDecFloat` has explicit conversions from `Decimal32`, `Decimal64` and `Decimal128`, and to `Decimal64` and `Decimal128`. + +```csharp +command.CommandText = "insert into t (d34) values (@d34)"; +command.Parameters.AddWithValue("d34", Decimal128.Parse("1234567890123456789012345678.901234", CultureInfo.InvariantCulture)); +... +var value = reader.GetFieldValue(0); +``` + ### Examples -Examples can be found in [`FbDecFloat16SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs) and [`FbDecFloat34SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs). +Examples can be found in [`FbDecFloat16SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs), [`FbDecFloat34SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs) and [`FbDecimalIeee754SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs). From 7b78dec714bcf43e4a736fa2bc80dc12595c1ac2 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 12:55:58 -0300 Subject: [PATCH 09/11] Convert IEEE decimal parameter values in FbParameter instead of DbValue Every parameter value reaches DbValue through FbParameter.InternalValue (FbCommand and FbBatchCommand, sync and async), so Decimal32/64/128 values are converted to FbDecFloat there. DbValue no longer checks the type of every value it stores: both clients store decoded row values in DbValue (the managed client through the constructors, the native client through SetValue), and those values are never IEEE decimals. DbValue's constructors and SetValue are back to their previous code. Adds a batch command test (Decimal64/Decimal128 parameters, bit-exact round trip), as FbBatchCommand takes the same path. --- .../FbBatchCommandTests.cs | 53 +++++++++++++++++++ .../Common/DbValue.cs | 22 ++------ .../FirebirdClient/FbParameter.cs | 9 ++++ 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbBatchCommandTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbBatchCommandTests.cs index 4c5e5947a..c50c236a5 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbBatchCommandTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbBatchCommandTests.cs @@ -66,6 +66,59 @@ public override async Task TearDown() await base.TearDown(); } +#if NET11_0_OR_GREATER + [Test] + public async Task Decimal128AndDecimal64ProperlyInDatabase() + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "recreate table batch_decfloat (id int, d16 decfloat(16), d34 decfloat(34))"; + await cmd.ExecuteNonQueryAsync(); + } + try + { + var d34 = new[] { Decimal128.Parse("1234567890123456789012345678.901234", System.Globalization.CultureInfo.InvariantCulture), Decimal128.Parse("-1.10", System.Globalization.CultureInfo.InvariantCulture) }; + var d16 = new[] { Decimal64.Parse("1.10", System.Globalization.CultureInfo.InvariantCulture), Decimal64.Parse("-9.999999999999999E+384", System.Globalization.CultureInfo.InvariantCulture) }; + await using (var cmd = Connection.CreateBatchCommand()) + { + cmd.CommandText = "insert into batch_decfloat values (@id, @d16, @d34)"; + for (var i = 0; i < d34.Length; i++) + { + var batch = cmd.AddBatchParameters(); + batch.Add("id", i); + batch.Add("d16", d16[i]); + batch.Add("d34", d34[i]); + } + var result = await cmd.ExecuteNonQueryAsync(); + Assert.IsTrue(result.AllSuccess); + } + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "select d16, d34 from batch_decfloat order by id"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + for (var i = 0; i < d34.Length; i++) + { + Assert.IsTrue(await reader.ReadAsync()); + // same bits: same value and same exponent + Assert.AreEqual(Decimal64.EncodeDecimal(d16[i]), Decimal64.EncodeDecimal(reader.GetFieldValue(0))); + Assert.AreEqual(Decimal128.EncodeDecimal(d34[i]), Decimal128.EncodeDecimal(reader.GetFieldValue(1))); + } + Assert.IsFalse(await reader.ReadAsync()); + } + } + } + finally + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "drop table batch_decfloat"; + await cmd.ExecuteNonQueryAsync(); + } + } + } +#endif + [Test] public async Task DataProperlyInDatabase() { diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index dd464c49d..1ecc92f92 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -40,30 +40,14 @@ public DbField Field public DbValue(DbField field, object value) { _field = field; - _value = Normalize(value) ?? DBNull.Value; + _value = value ?? DBNull.Value; } public DbValue(StatementBase statement, DbField field, object value) { _statement = statement; _field = field; - _value = Normalize(value) ?? DBNull.Value; - } - - // IEEE 754 decimal values are kept as FbDecFloat (exact), so every conversion that handles FbDecFloat handles them too. - static object Normalize(object value) - { -#if NET11_0_OR_GREATER - return value switch - { - Decimal128 d => (FbDecFloat)d, - Decimal64 d => (FbDecFloat)d, - Decimal32 d => (FbDecFloat)d, - _ => value, - }; -#else - return value; -#endif + _value = value ?? DBNull.Value; } public bool IsDBNull() @@ -160,7 +144,7 @@ public async ValueTask GetValueAsync(CancellationToken cancellationToken public void SetValue(object value) { - _value = Normalize(value); + _value = value; } public string GetString() diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbParameter.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbParameter.cs index dc839beda..728892a5f 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbParameter.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbParameter.cs @@ -242,6 +242,15 @@ internal object InternalValue var result = new byte[Math.Min(Size, bvalue.Length)]; Array.Copy(bvalue, result, result.Length); return result; +#if NET11_0_OR_GREATER + // IEEE 754 decimal values are passed on as FbDecFloat (exact), so every conversion that handles FbDecFloat handles them too. + case System.Numerics.Decimal128 d128: + return (Types.FbDecFloat)d128; + case System.Numerics.Decimal64 d64: + return (Types.FbDecFloat)d64; + case System.Numerics.Decimal32 d32: + return (Types.FbDecFloat)d32; +#endif default: return _value; } From 75d25c307faf43123075fd78cd52b738c079f5c9 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 11:16:22 -0300 Subject: [PATCH 10/11] Add "DecFloat Type" connection option to return DECFLOAT as Decimal64/Decimal128 With "DecFloat Type=Ieee754" (FbConnectionStringBuilder.DecFloatType = FbDecFloatType.Ieee754), DECFLOAT(16) values are returned as System.Numerics.Decimal64 and DECFLOAT(34) values as Decimal128 by FbDataReader.GetValue/GetValues/indexers/GetFieldValue, GetFieldType/GetSchemaTable (so DataTable/DataAdapter columns get those types), FbCommand.ExecuteScalar and output parameters. The default, FbDecFloatType.FbDecFloat, keeps the current behavior. Ieee754 requires .NET 11 or later: on older target frameworks the option is rejected when the connection string is validated. The conversion is exact (the field's precision always fits the IEEE type), and the typed accessors (GetFieldValue, GetDecimal, ...) are unaffected. --- .../ConnectionStringTests.cs | 34 ++++++ .../FbConnectionStringBuilderTests.cs | 15 +++ .../FbDecimalIeee754SupportTests.cs | 108 ++++++++++++++++++ .../Common/ConnectionString.cs | 22 ++++ .../Common/DbField.cs | 6 + .../Common/DbValue.cs | 10 ++ .../Common/TypeHelper.cs | 36 ++++++ .../FirebirdClient/FbCommand.cs | 8 +- .../FbConnectionStringBuilder.cs | 26 +++++ .../FirebirdClient/FbDataReader.cs | 8 +- .../FirebirdClient/FbDecFloatType.cs | 30 +++++ 11 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDecFloatType.cs diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs index fb696d29f..d242b0b64 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/ConnectionStringTests.cs @@ -15,6 +15,7 @@ //$Authors = Jiri Cincura (jiri@cincura.net) +using System; using System.Globalization; using System.Threading; using FirebirdSql.Data.Common; @@ -182,6 +183,39 @@ public void WireCryptMixedCase() Assert.AreEqual(FbWireCrypt.Required, cs.WireCrypt); } + [Test] + public void DecFloatTypeDefault() + { + var cs = new ConnectionString("database=test.fdb"); + Assert.AreEqual(FbDecFloatType.FbDecFloat, cs.DecFloatType); + } + + [TestCase("decfloat type=ieee754")] + [TestCase("DecFloatType=Ieee754")] + public void DecFloatTypeParsing(string connectionString) + { + var cs = new ConnectionString(connectionString); + Assert.AreEqual(FbDecFloatType.Ieee754, cs.DecFloatType); + } + + [Test] + public void DecFloatTypeInvalidValue() + { + Assert.Throws(() => new ConnectionString("decfloat type=decimal")); + } + + [Test] + public void DecFloatTypeIeee754Validation() + { + var cs = new ConnectionString("database=localhost:test.fdb;user=sysdba;password=masterkey;decfloat type=ieee754"); +#if NET11_0_OR_GREATER + Assert.DoesNotThrow(cs.Validate); +#else + var ex = Assert.Throws(cs.Validate); + StringAssert.Contains("'DecFloat Type'", ex.Message); +#endif + } + [Test] public void ParsingDatabaseOldStyleHostnameWithoutPortWithoutPath() { diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbConnectionStringBuilderTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbConnectionStringBuilderTests.cs index aaffaf885..c67bd3516 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbConnectionStringBuilderTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbConnectionStringBuilderTests.cs @@ -68,4 +68,19 @@ public void WireCryptGetter() var b = new FbConnectionStringBuilder("wire crypt=required"); Assert.AreEqual(FbWireCrypt.Required, b.WireCrypt); } + + [Test] + public void DecFloatTypeSetter() + { + var b = new FbConnectionStringBuilder(); + b.DecFloatType = FbDecFloatType.Ieee754; + Assert.AreEqual("decfloat type=Ieee754", b.ToString()); + } + + [Test] + public void DecFloatTypeGetter() + { + Assert.AreEqual(FbDecFloatType.FbDecFloat, new FbConnectionStringBuilder().DecFloatType); + Assert.AreEqual(FbDecFloatType.Ieee754, new FbConnectionStringBuilder("decfloattype=ieee754").DecFloatType); + } } diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs index ada460b3b..fb21dfc3c 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs @@ -258,6 +258,114 @@ public async Task InsertsAndReadsBackDecimal128() await cmd.ExecuteNonQueryAsync(); } } + + async Task OpenIeee754ConnectionAsync() + { + var builder = BuildConnectionStringBuilder(ServerType, Compression, WireCrypt); + builder.DecFloatType = FbDecFloatType.Ieee754; + var connection = new FbConnection(builder.ToString()); + await connection.OpenAsync(); + return connection; + } + + [Test] + public async Task DecFloatTypeIeee754ReturnsDecimal64AndDecimal128() + { + await using (var connection = await OpenIeee754ConnectionAsync()) + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "select cast('1.10' as decfloat(16)), cast('-1.10' as decfloat(34)), cast(null as decfloat(34)), cast(1.5 as numeric(18, 2)) from rdb$database"; + await using (var reader = await cmd.ExecuteReaderAsync()) + { + Assert.AreEqual(typeof(Decimal64), reader.GetFieldType(0)); + Assert.AreEqual(typeof(Decimal128), reader.GetFieldType(1)); + Assert.AreEqual(typeof(Decimal128), reader.GetFieldType(2)); + Assert.AreEqual(typeof(decimal), reader.GetFieldType(3)); + Assert.AreEqual("DECFLOAT", reader.GetDataTypeName(1)); + + Assert.IsTrue(await reader.ReadAsync()); + var values = new object[4]; + reader.GetValues(values); + Assert.IsInstanceOf(values[0]); + AssertSameValueAndExponent(D64("1.10"), (Decimal64)values[0]); + Assert.IsInstanceOf(values[1]); + AssertSameValueAndExponent(D128("-1.10"), (Decimal128)values[1]); + Assert.AreEqual(DBNull.Value, values[2]); + Assert.AreEqual(1.5m, values[3]); + Assert.IsInstanceOf(reader[1]); + Assert.IsInstanceOf(await reader.GetFieldValueAsync(1)); + // the other accessors are not affected + Assert.AreEqual(new FbDecFloat(-110, -2), reader.GetFieldValue(1)); + Assert.AreEqual(-1.10m, reader.GetDecimal(1)); + + var schema = await reader.GetSchemaTableAsync(); + Assert.AreEqual(typeof(Decimal64), schema.Rows[0]["DataType"]); + Assert.AreEqual(typeof(Decimal128), schema.Rows[1]["DataType"]); + } + } + } + + [Test] + public async Task DecFloatTypeIeee754ExecuteScalarReturnsDecimal128() + { + await using (var connection = await OpenIeee754ConnectionAsync()) + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "select cast('1234567890123456789012345678901234' as decfloat(34)) from rdb$database"; + var result = await cmd.ExecuteScalarAsync(); + Assert.IsInstanceOf(result); + AssertSameValueAndExponent(D128("1234567890123456789012345678901234"), (Decimal128)result); + Assert.IsInstanceOf(cmd.ExecuteScalar()); + } + } + + [Test] + public async Task DecFloatTypeIeee754OutputParameters() + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "recreate procedure decimal128_proc (i decfloat(34)) returns (o34 decfloat(34), o16 decfloat(16)) as begin o34 = i; o16 = 1.5; end"; + await cmd.ExecuteNonQueryAsync(); + } + try + { + await using (var connection = await OpenIeee754ConnectionAsync()) + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "decimal128_proc"; + cmd.CommandType = System.Data.CommandType.StoredProcedure; + cmd.Parameters.Add("i", D128("-0.00")); + var o34 = cmd.Parameters.Add(new FbParameter("o34", FbDbType.Dec34) { Direction = System.Data.ParameterDirection.Output }); + var o16 = cmd.Parameters.Add(new FbParameter("o16", FbDbType.Dec16) { Direction = System.Data.ParameterDirection.Output }); + await cmd.ExecuteNonQueryAsync(); + Assert.IsInstanceOf(o34.Value); + AssertSameValueAndExponent(D128("-0.00"), (Decimal128)o34.Value); + Assert.IsInstanceOf(o16.Value); + AssertSameValueAndExponent(D64("1.5"), (Decimal64)o16.Value); + } + } + finally + { + await using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = "drop procedure decimal128_proc"; + await cmd.ExecuteNonQueryAsync(); + } + } + } + + [Test] + public async Task DecFloatTypeIeee754DataAdapterFill() + { + await using (var connection = await OpenIeee754ConnectionAsync()) + using (var adapter = new FbDataAdapter("select cast('1.10' as decfloat(34)) as d34 from rdb$database", connection)) + { + var table = new System.Data.DataTable(); + adapter.Fill(table); + Assert.AreEqual(typeof(Decimal128), table.Columns["D34"].DataType); + AssertSameValueAndExponent(D128("1.10"), (Decimal128)table.Rows[0]["D34"]); + } + } } #endif diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs b/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs index 5192e7857..bb3d97d14 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/ConnectionString.cs @@ -58,6 +58,7 @@ internal sealed class ConnectionString internal const string DefaultValueApplicationName = ""; internal const int DefaultValueCommandTimeout = 0; internal const int DefaultValueParallelWorkers = 0; + internal const FbDecFloatType DefaultValueDecFloatType = FbDecFloatType.FbDecFloat; internal const string DefaultKeyUserId = "user id"; internal const string DefaultKeyPortNumber = "port number"; @@ -88,6 +89,7 @@ internal sealed class ConnectionString internal const string DefaultKeyApplicationName = "application name"; internal const string DefaultKeyCommandTimeout = "command timeout"; internal const string DefaultKeyParallelWorkers = "parallel workers"; + internal const string DefaultKeyDecFloatType = "decfloat type"; #endregion #region Static Fields @@ -163,6 +165,8 @@ internal sealed class ConnectionString { DefaultKeyParallelWorkers, DefaultKeyParallelWorkers }, { "parallelworkers", DefaultKeyParallelWorkers }, { "parallel", DefaultKeyParallelWorkers }, + { DefaultKeyDecFloatType, DefaultKeyDecFloatType }, + { "decfloattype", DefaultKeyDecFloatType }, }; internal static readonly IDictionary DefaultValues = new Dictionary(StringComparer.Ordinal) @@ -196,6 +200,7 @@ internal sealed class ConnectionString { DefaultKeyApplicationName, DefaultValueApplicationName }, { DefaultKeyCommandTimeout, DefaultValueCommandTimeout }, { DefaultKeyParallelWorkers, DefaultValueParallelWorkers }, + { DefaultKeyDecFloatType, DefaultValueDecFloatType }, }; #endregion @@ -237,6 +242,7 @@ internal sealed class ConnectionString public string ApplicationName => GetString(DefaultKeyApplicationName, _options.TryGetValue); public int CommandTimeout => GetInt32(DefaultKeyCommandTimeout, _options.TryGetValue); public int ParallelWorkers => GetInt32(DefaultKeyParallelWorkers, _options.TryGetValue); + public FbDecFloatType DecFloatType => GetDecFloatType(DefaultKeyDecFloatType, _options.TryGetValue); #endregion @@ -302,6 +308,12 @@ public void Validate() { throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, "'Parallel Workers' value of {0} is not valid.{1}The value should be an integer >= 0.", ParallelWorkers, Environment.NewLine)); } +#if !NET11_0_OR_GREATER + if (DecFloatType == FbDecFloatType.Ieee754) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, "'DecFloat Type' value of {0} requires .NET 11 or later.", DecFloatType)); + } +#endif } #endregion @@ -361,6 +373,9 @@ private void Load(string connectionString) case DefaultKeyWireCrypt: _options[key] = ParseEnum(values[1], DefaultKeyWireCrypt); break; + case DefaultKeyDecFloatType: + _options[key] = ParseEnum(values[1], DefaultKeyDecFloatType); + break; default: _options[key] = values[1]; break; @@ -565,6 +580,13 @@ internal static FbWireCrypt GetWireCrypt(string key, TryGetValueDelegate tryGetV : defaultValue; } + internal static FbDecFloatType GetDecFloatType(string key, TryGetValueDelegate tryGetValue, FbDecFloatType defaultValue = default) + { + return tryGetValue(key, out var value) + ? (FbDecFloatType)value + : defaultValue; + } + #endregion #region Private Static Methods diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs index 3be89be74..9c0164761 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbField.cs @@ -18,6 +18,7 @@ using System; using System.Linq; using System.Numerics; +using FirebirdSql.Data.FirebirdClient; using FirebirdSql.Data.Types; namespace FirebirdSql.Data.Common; @@ -552,6 +553,11 @@ public Type GetSystemType() return TypeHelper.GetTypeFromDbDataType(DbDataType); } + public Type GetSystemType(FbDecFloatType decFloatType) + { + return TypeHelper.GetTypeFromDbDataType(DbDataType, decFloatType); + } + public bool HasDataType() { return _dataType != 0; diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 1ecc92f92..b440a56a1 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs @@ -55,6 +55,16 @@ public bool IsDBNull() return TypeHelper.IsDBNull(_value); } + // GetValue honoring the DecFloatType connection option (values returned to the client). + public object GetValue(FbDecFloatType decFloatType) + { + return TypeHelper.GetClientValue(GetValue(), _field.DbDataType, decFloatType); + } + public async ValueTask GetValueAsync(FbDecFloatType decFloatType, CancellationToken cancellationToken = default) + { + return TypeHelper.GetClientValue(await GetValueAsync(cancellationToken).ConfigureAwait(false), _field.DbDataType, decFloatType); + } + public object GetValue() { if (IsDBNull()) diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs b/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs index 9f08b961e..1297d150c 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/TypeHelper.cs @@ -326,6 +326,42 @@ public static string GetDataTypeName(DbDataType type) } } + // CLR type of a field as returned to the client, honoring the DecFloatType connection option. + public static Type GetTypeFromDbDataType(DbDataType type, FbDecFloatType decFloatType) + { +#if NET11_0_OR_GREATER + if (decFloatType == FbDecFloatType.Ieee754) + { + switch (type) + { + case DbDataType.Dec16: + return typeof(System.Numerics.Decimal64); + case DbDataType.Dec34: + return typeof(System.Numerics.Decimal128); + } + } +#endif + return GetTypeFromDbDataType(type); + } + + // Value of a field as returned to the client, honoring the DecFloatType connection option. + public static object GetClientValue(object value, DbDataType type, FbDecFloatType decFloatType) + { +#if NET11_0_OR_GREATER + if (decFloatType == FbDecFloatType.Ieee754 && value is FbDecFloat decFloat) + { + switch (type) + { + case DbDataType.Dec16: + return (System.Numerics.Decimal64)decFloat; + case DbDataType.Dec34: + return (System.Numerics.Decimal128)decFloat; + } + } +#endif + return value; + } + public static Type GetTypeFromDbDataType(DbDataType type) { switch (type) diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs index 551c6ea64..d4207783a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs @@ -659,7 +659,7 @@ public override object ExecuteScalar() // Get the return value if (values != null && values.Length > 0) { - val = values[0].GetValue(); + val = values[0].GetValue(_connection.ConnectionOptions.DecFloatType); } CommitImplicitTransaction(); @@ -706,7 +706,7 @@ public override async Task ExecuteScalarAsync(CancellationToken cancella // Get the return value if (values != null && values.Length > 0) { - val = await values[0].GetValueAsync(explicitCancellation.CancellationToken).ConfigureAwait(false); + val = await values[0].GetValueAsync(_connection.ConnectionOptions.DecFloatType, explicitCancellation.CancellationToken).ConfigureAwait(false); } await CommitImplicitTransactionAsync(explicitCancellation.CancellationToken).ConfigureAwait(false); @@ -870,7 +870,7 @@ internal void SetOutputParameters(DbValue[] outputParameterValues) parameter.Direction == ParameterDirection.InputOutput || parameter.Direction == ParameterDirection.ReturnValue) { - parameter.Value = values[i].GetValue(); + parameter.Value = values[i].GetValue(_connection.ConnectionOptions.DecFloatType); i++; } } @@ -900,7 +900,7 @@ internal async Task SetOutputParametersAsync(DbValue[] outputParameterValues, Ca parameter.Direction == ParameterDirection.InputOutput || parameter.Direction == ParameterDirection.ReturnValue) { - parameter.Value = await values[i].GetValueAsync(cancellationToken).ConfigureAwait(false); + parameter.Value = await values[i].GetValueAsync(_connection.ConnectionOptions.DecFloatType, cancellationToken).ConfigureAwait(false); i++; } } diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs index fa6433f38..25a8cd6ad 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionStringBuilder.cs @@ -319,6 +319,16 @@ public int ParallelWorkers set { SetValue(Common.ConnectionString.DefaultKeyParallelWorkers, value); } } + [Category("Advanced")] + [DisplayName("DecFloat Type")] + [Description("CLR type of DECFLOAT values: FbDecFloat, or Ieee754 for System.Numerics.Decimal64/Decimal128 (.NET 11 or later).")] + [DefaultValue(Common.ConnectionString.DefaultValueDecFloatType)] + public FbDecFloatType DecFloatType + { + get { return GetDecFloatType(Common.ConnectionString.DefaultKeyDecFloatType, Common.ConnectionString.DefaultValueDecFloatType); } + set { SetValue(Common.ConnectionString.DefaultKeyDecFloatType, value); } + } + #endregion #region Constructors @@ -384,6 +394,22 @@ private FbWireCrypt GetWireCrypt(string keyword, FbWireCrypt defaultValue) } } + private FbDecFloatType GetDecFloatType(string keyword, FbDecFloatType defaultValue) + { + var key = GetKey(keyword); + if (!TryGetValue(key, out var value)) + return defaultValue; + switch (value) + { + case FbDecFloatType fbDecFloatType: + return fbDecFloatType; + case string s when Enum.TryParse(s, true, out var enumResult): + return enumResult; + default: + return Common.ConnectionString.GetDecFloatType(key, base.TryGetValue, defaultValue); + } + } + private byte[] GetBytes(string keyword, byte[] defaultValue) { var key = GetKey(keyword); diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs index 45737535a..67cd69f6d 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs @@ -54,6 +54,7 @@ public sealed class FbDataReader : DbDataReader private int _recordsAffected; private Dictionary _columnsIndexesOrdinal; private Dictionary _columnsIndexesOrdinalCI; + private FbDecFloatType _decFloatType; #endregion @@ -84,6 +85,7 @@ internal FbDataReader(FbCommand command, FbConnection connection, CommandBehavio _connection = connection; _commandBehavior = commandBehavior; _fields = _command.GetFieldsDescriptor(); + _decFloatType = connection?.ConnectionOptions?.DecFloatType ?? default; UpdateRecordsAffected(); } @@ -538,7 +540,7 @@ public override Type GetFieldType(int i) CheckState(); CheckIndex(i); - return _fields[i].GetSystemType(); + return _fields[i].GetSystemType(_decFloatType); } public override Type GetProviderSpecificFieldType(int i) @@ -695,7 +697,7 @@ public override T GetFieldValue(int i) } else { - return (T)_row[i].GetValue(); + return (T)_row[i].GetValue(_decFloatType); } } catch (IscException ex) @@ -806,7 +808,7 @@ public override async Task GetFieldValueAsync(int i, CancellationToken can } else { - return (T)await _row[i].GetValueAsync().ConfigureAwait(false); + return (T)await _row[i].GetValueAsync(_decFloatType).ConfigureAwait(false); } } catch (IscException ex) diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDecFloatType.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDecFloatType.cs new file mode 100644 index 000000000..b768640ae --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDecFloatType.cs @@ -0,0 +1,30 @@ +/* + * 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; + +namespace FirebirdSql.Data.FirebirdClient; + +// CLR type of DECFLOAT values returned by FbDataReader.GetValue/GetFieldType, FbCommand.ExecuteScalar and output parameters. +[Serializable] +public enum FbDecFloatType +{ + // DECFLOAT(16) and DECFLOAT(34) values are FirebirdSql.Data.Types.FbDecFloat. + FbDecFloat, + // DECFLOAT(16) values are System.Numerics.Decimal64 and DECFLOAT(34) values are System.Numerics.Decimal128 (.NET 11 or later). + Ieee754, +} From c5498fc937355af0bfe30d9a264a4afea7f6e940 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 7 Nov 2026 11:17:06 -0300 Subject: [PATCH 11/11] Document the DecFloat Type connection option --- docs/decfloat.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/decfloat.md b/docs/decfloat.md index e93641c76..d1174a395 100644 --- a/docs/decfloat.md +++ b/docs/decfloat.md @@ -36,6 +36,8 @@ command.Parameters.AddWithValue("d34", Decimal128.Parse("12345678901234567890123 var value = reader.GetFieldValue(0); ``` +By default `GetValue`, `GetFieldType`, `ExecuteScalar` and output parameters use `FbDecFloat`. With the connection string option `DecFloat Type=Ieee754` (`FbConnectionStringBuilder.DecFloatType = FbDecFloatType.Ieee754`) they use `Decimal64` for `DECFLOAT(16)` and `Decimal128` for `DECFLOAT(34)` instead, so that `DataTable`s, `DataAdapter`s and other code relying on `GetValue` get the .NET types. This option requires .NET 11 or later. + ### Examples Examples can be found in [`FbDecFloat16SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat16SupportTests.cs), [`FbDecFloat34SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecFloat34SupportTests.cs) and [`FbDecimalIeee754SupportTests`](../src/FirebirdSql.Data.FirebirdClient.Tests/FbDecimalIeee754SupportTests.cs).