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). 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..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; @@ -63,8 +64,25 @@ 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" }, + 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[] + { + 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))] public async Task ReadsValueCorrectly(FbDecFloat value, string castValue) { @@ -88,6 +106,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() { @@ -111,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 b1c2bf39f..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; @@ -63,8 +64,25 @@ 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" }, + 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[] + { + 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 +106,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() { @@ -111,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 4df15b2d5..e44e697ea 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,190 @@ 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); + var negativeDoubles = 0; + var negativeSingles = 0; + for (var i = 0; i < 10_000; i++) + { + // 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); + } + 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/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; diff --git a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs index 29c37c4ee..c901fe992 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs @@ -64,16 +64,120 @@ 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); + } + + // 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() { @@ -155,10 +259,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); } }