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/docs/decfloat.md b/docs/decfloat.md index 3bb60e305..e93641c76 100644 --- a/docs/decfloat.md +++ b/docs/decfloat.md @@ -2,6 +2,40 @@ `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. + +### .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). 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/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.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/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/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.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.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/Common/DbValue.cs b/src/FirebirdSql.Data.FirebirdClient/Common/DbValue.cs index 44b79a0f1..1ecc92f92 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() @@ -260,9 +276,39 @@ 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 (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/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/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; } 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/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs b/src/FirebirdSql.Data.FirebirdClient/Types/FbDecFloat.cs index 29c37c4ee..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; @@ -64,16 +65,163 @@ 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}."); + +#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() + { + 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 +303,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); } } 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 + } } }