Skip to content

Commit 07d2eeb

Browse files
committed
Read INT128-backed NUMERIC/DECIMAL values without overflowing or rounding
NUMERIC/DECIMAL with precision 19-38 were converted to System.Decimal while the row was decoded: - Values beyond System.Decimal (e.g. 38 digits) threw OverflowException in the middle of decoding the row. On the managed (wire) client this left the rest of the packet unread and desynchronized the connection: every later command on it failed with "ArgumentOutOfRangeException: operation=...". - Values with large scale but small magnitude overflowed too, e.g. 1.000...0 as NUMERIC(38,30). - Values with more than 28 fractional digits were rounded silently, e.g. 1E-30 as NUMERIC(38,30) was read as 0. - The native client returned BigInteger for NUMERIC(38,0)/DECIMAL(38,0) although GetFieldType() reports System.Decimal (managed returned decimal). Now the value is converted exactly: values that fit System.Decimal are returned exactly as before (smallest scale, e.g. 1234.5), on both clients. Values that do not fit are kept exactly (internal BigDecimal), so the row is always read and the connection stays usable; for those values: - GetValue/GetDecimal/GetFieldValue<decimal> throw OverflowException (only when that column is accessed), - GetString returns the exact value, - GetDouble/GetFloat return the nearest value, - GetFieldValue<BigInteger> returns integral values exactly (also for values that fit decimal; fractional values throw OverflowException). As GetFieldValue<BigInteger> and INT128 parameters share DbValue.GetInt128, an integral decimal parameter value can now be passed to an INT128 parameter (it threw InvalidCastException before).
1 parent fef2e3a commit 07d2eeb

8 files changed

Lines changed: 414 additions & 6 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* The contents of this file are subject to the Initial
3+
* Developer's Public License Version 1.0 (the "License");
4+
* you may not use this file except in compliance with the
5+
* License. You may obtain a copy of the License at
6+
* https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt.
7+
*
8+
* Software distributed under the License is distributed on
9+
* an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either
10+
* express or implied. See the License for the specific
11+
* language governing rights and limitations under the License.
12+
*
13+
* All Rights Reserved.
14+
*/
15+
16+
//$Authors = F.D.Castel
17+
18+
using System;
19+
using System.Globalization;
20+
using System.Numerics;
21+
using FirebirdSql.Data.Common;
22+
using FirebirdSql.Data.TestsBase;
23+
using NUnit.Framework;
24+
25+
namespace FirebirdSql.Data.FirebirdClient.Tests;
26+
27+
[NoServerCategory]
28+
public class BigDecimalTests
29+
{
30+
static readonly object[] FitsDecimalSource = new object[]
31+
{
32+
new object[] { "0", 0, 0m },
33+
new object[] { "0", 30, 0m },
34+
new object[] { "1234500000", 6, 1234.5m },
35+
new object[] { "1234567", 6, 1.234567m },
36+
new object[] { "-1234567", 6, -1.234567m },
37+
new object[] { "1000000000000000000000000000000", 30, 1m },
38+
new object[] { "79228162514264337593543950335", 0, decimal.MaxValue },
39+
new object[] { "-79228162514264337593543950335", 0, decimal.MinValue },
40+
new object[] { "792281625142643375935439503350", 1, decimal.MaxValue },
41+
new object[] { "1", 28, 0.0000000000000000000000000001m },
42+
new object[] { "79228162514264337593543950335", 28, 7.9228162514264337593543950335m },
43+
};
44+
[TestCaseSource(nameof(FitsDecimalSource))]
45+
public void CreatesDecimalWithSmallestScaleWhenExact(string unscaled, int scale, decimal expected)
46+
{
47+
var result = BigDecimal.Create(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale);
48+
Assert.IsInstanceOf<decimal>(result);
49+
Assert.AreEqual(expected, (decimal)result);
50+
Assert.AreEqual(expected.Scale, ((decimal)result).Scale);
51+
}
52+
53+
static readonly object[] DoesNotFitDecimalSource = new object[]
54+
{
55+
new object[] { "79228162514264337593543950336", 0, "79228162514264337593543950336" },
56+
new object[] { "-79228162514264337593543950336", 0, "-79228162514264337593543950336" },
57+
new object[] { "1", 29, "0.00000000000000000000000000001" },
58+
new object[] { "-123", 30, "-0.000000000000000000000000000123" },
59+
new object[] { "12345678901234567890123456789012345678", 6, "12345678901234567890123456789012.345678" },
60+
new object[] { "123456789012345678901234567890000", 3, "123456789012345678901234567890" },
61+
};
62+
[TestCaseSource(nameof(DoesNotFitDecimalSource))]
63+
public void CreatesBigDecimalWhenNotExact(string unscaled, int scale, string expected)
64+
{
65+
var result = BigDecimal.Create(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale);
66+
Assert.IsInstanceOf<BigDecimal>(result);
67+
Assert.AreEqual(expected, ((BigDecimal)result).ToString(CultureInfo.InvariantCulture));
68+
Assert.Throws<OverflowException>(() => Convert.ToDecimal(result, CultureInfo.InvariantCulture));
69+
Assert.AreEqual(double.Parse(expected, CultureInfo.InvariantCulture), Convert.ToDouble(result, CultureInfo.InvariantCulture));
70+
Assert.AreEqual(expected, Convert.ToString(result, CultureInfo.InvariantCulture));
71+
}
72+
73+
[Test]
74+
public void FormatsWithCulture()
75+
{
76+
var value = new BigDecimal(BigInteger.Parse("-12345678901234567890123456789012345678"), 6);
77+
Assert.AreEqual("-12345678901234567890123456789012,345678", value.ToString(new CultureInfo("pt-BR")));
78+
Assert.AreEqual("-12345678901234567890123456789012.345678", value.ToString(new CultureInfo("en-US")));
79+
}
80+
81+
[TestCase("25", 1, 2)]
82+
[TestCase("35", 1, 4)]
83+
[TestCase("-25", 1, -2)]
84+
[TestCase("-35", 1, -4)]
85+
[TestCase("26", 1, 3)]
86+
[TestCase("24", 1, 2)]
87+
[TestCase("123", 0, 123)]
88+
public void ConvertsToIntegerRoundingToEven(string unscaled, int scale, long expected)
89+
{
90+
var value = new BigDecimal(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale);
91+
Assert.AreEqual(expected, Convert.ToInt64(value, CultureInfo.InvariantCulture));
92+
Assert.AreEqual((int)expected, Convert.ToInt32(value, CultureInfo.InvariantCulture));
93+
}
94+
95+
[Test]
96+
public void ConvertsToIntegerThrowsOnOverflow()
97+
{
98+
var value = new BigDecimal(BigInteger.Parse("12345678901234567890123456789012345678"), 0);
99+
Assert.Throws<OverflowException>(() => Convert.ToInt64(value, CultureInfo.InvariantCulture));
100+
}
101+
102+
[TestCase("12345678901234567890123456789012345678", 0, true, "12345678901234567890123456789012345678")]
103+
[TestCase("12345678901234567890123456789012000000", 6, true, "12345678901234567890123456789012")]
104+
[TestCase("12345678901234567890123456789012345678", 6, false, null)]
105+
public void ConvertsToBigIntegerOnlyWhenIntegral(string unscaled, int scale, bool expectedSuccess, string expected)
106+
{
107+
var value = new BigDecimal(BigInteger.Parse(unscaled, CultureInfo.InvariantCulture), scale);
108+
Assert.AreEqual(expectedSuccess, value.TryToBigInteger(out var result));
109+
if (expectedSuccess)
110+
{
111+
Assert.AreEqual(BigInteger.Parse(expected, CultureInfo.InvariantCulture), result);
112+
}
113+
}
114+
}

‎src/FirebirdSql.Data.FirebirdClient.Tests/FbLongNumericsSupportTests.cs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
using System;
1919
using System.Globalization;
20+
using System.Numerics;
2021
using System.Threading.Tasks;
2122
using FirebirdSql.Data.TestsBase;
2223
using NUnit.Framework;
@@ -70,6 +71,108 @@ public async Task PassesValueCorrectly(decimal value, int scale)
7071
}
7172
}
7273

74+
static readonly object[] FitsDecimalValues = new[]
75+
{
76+
new object[] { "1.000000000000000000000000000000", 30, 1m },
77+
new object[] { "1234.500000", 6, 1234.5m },
78+
new object[] { "123", 0, 123m },
79+
new object[] { "79228162514264337593543950335", 0, decimal.MaxValue },
80+
new object[] { "-79228162514264337593543950335", 0, decimal.MinValue },
81+
new object[] { "0.0000000000000000000000000001", 28, 0.0000000000000000000000000001m },
82+
new object[] { "0.000000000000000000000000000100", 30, 0.0000000000000000000000000001m },
83+
};
84+
85+
[TestCaseSource(nameof(FitsDecimalValues))]
86+
public async Task ReadsValueFittingDecimalExactly(string literal, int scale, decimal expected)
87+
{
88+
await using (var cmd = Connection.CreateCommand())
89+
{
90+
cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database";
91+
await using (var reader = await cmd.ExecuteReaderAsync())
92+
{
93+
await reader.ReadAsync();
94+
Assert.AreEqual(typeof(decimal), reader.GetFieldType(0));
95+
var result = reader.GetValue(0);
96+
Assert.IsInstanceOf<decimal>(result);
97+
Assert.AreEqual(expected, (decimal)result);
98+
Assert.AreEqual(expected.ToString(CultureInfo.InvariantCulture), ((decimal)result).ToString(CultureInfo.InvariantCulture));
99+
}
100+
}
101+
}
102+
103+
static readonly object[] DoesNotFitDecimalValues = new[]
104+
{
105+
new object[] { "12345678901234567890123456789012345678", 0 },
106+
new object[] { "-12345678901234567890123456789012345678", 0 },
107+
new object[] { "79228162514264337593543950336", 0 },
108+
new object[] { "12345678901234567890123456789012.345678", 6 },
109+
new object[] { "0.000000000000000000000000000001", 30 },
110+
new object[] { "-0.000123456789012345678901234567", 30 },
111+
};
112+
113+
[TestCaseSource(nameof(DoesNotFitDecimalValues))]
114+
public async Task ReadsValueNotFittingDecimalWithoutFailingTheRow(string literal, int scale)
115+
{
116+
await using (var cmd = Connection.CreateCommand())
117+
{
118+
cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})), 1 from rdb$database";
119+
await using (var reader = await cmd.ExecuteReaderAsync())
120+
{
121+
Assert.IsTrue(await reader.ReadAsync());
122+
Assert.AreEqual(1, reader.GetInt32(1));
123+
var format = NumberFormatInfo.CurrentInfo;
124+
Assert.AreEqual(literal.Replace("-", format.NegativeSign).Replace(".", format.NumberDecimalSeparator), reader.GetString(0));
125+
Assert.AreEqual(double.Parse(literal, CultureInfo.InvariantCulture), reader.GetDouble(0));
126+
Assert.Throws<OverflowException>(() => reader.GetValue(0));
127+
Assert.Throws<OverflowException>(() => reader.GetDecimal(0));
128+
Assert.Throws<OverflowException>(() => reader.GetFieldValue<decimal>(0));
129+
}
130+
}
131+
await using (var cmd = Connection.CreateCommand())
132+
{
133+
cmd.CommandText = "select cast('1234.5' as numeric(38, 6)) from rdb$database";
134+
Assert.AreEqual(1234.5m, await cmd.ExecuteScalarAsync(), "connection must remain usable");
135+
}
136+
}
137+
138+
static readonly object[] BigIntegerValues = new[]
139+
{
140+
new object[] { "12345678901234567890123456789012345678", 0, "12345678901234567890123456789012345678" },
141+
new object[] { "-12345678901234567890123456789012345678", 0, "-12345678901234567890123456789012345678" },
142+
new object[] { "123", 0, "123" },
143+
new object[] { "12345678901234567890123456789012.000000", 6, "12345678901234567890123456789012" },
144+
new object[] { "1234.000000", 6, "1234" },
145+
};
146+
147+
[TestCaseSource(nameof(BigIntegerValues))]
148+
public async Task ReadsIntegralValueAsBigInteger(string literal, int scale, string expected)
149+
{
150+
await using (var cmd = Connection.CreateCommand())
151+
{
152+
cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database";
153+
await using (var reader = await cmd.ExecuteReaderAsync())
154+
{
155+
await reader.ReadAsync();
156+
Assert.AreEqual(BigInteger.Parse(expected, CultureInfo.InvariantCulture), reader.GetFieldValue<BigInteger>(0));
157+
}
158+
}
159+
}
160+
161+
[TestCase("1234.5", 6)]
162+
[TestCase("12345678901234567890123456789012.5", 6)]
163+
public async Task ReadsFractionalValueAsBigIntegerThrows(string literal, int scale)
164+
{
165+
await using (var cmd = Connection.CreateCommand())
166+
{
167+
cmd.CommandText = $"select cast('{literal}' as numeric(38, {scale})) from rdb$database";
168+
await using (var reader = await cmd.ExecuteReaderAsync())
169+
{
170+
await reader.ReadAsync();
171+
Assert.Throws<OverflowException>(() => reader.GetFieldValue<BigInteger>(0));
172+
}
173+
}
174+
}
175+
73176
[Test]
74177
public async Task ReadsValueNullCorrectly()
75178
{

‎src/FirebirdSql.Data.FirebirdClient/Client/Managed/Version10/GdsStatement.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,6 +1569,10 @@ protected object ReadRawValue(IXdrReader xdr, DbField field)
15691569

15701570
case DbDataType.Decimal:
15711571
case DbDataType.Numeric:
1572+
if ((field.DataType & ~1) == IscCodes.SQL_INT128)
1573+
{
1574+
return TypeDecoder.DecodeInt128Numeric(xdr.ReadInt128(), field.NumericScale);
1575+
}
15721576
return xdr.ReadDecimal(field.DataType, field.NumericScale);
15731577

15741578
case DbDataType.Float:
@@ -1667,6 +1671,10 @@ protected async ValueTask<object> ReadRawValueAsync(IXdrReader xdr, DbField fiel
16671671

16681672
case DbDataType.Decimal:
16691673
case DbDataType.Numeric:
1674+
if ((field.DataType & ~1) == IscCodes.SQL_INT128)
1675+
{
1676+
return TypeDecoder.DecodeInt128Numeric(await xdr.ReadInt128Async(cancellationToken).ConfigureAwait(false), field.NumericScale);
1677+
}
16701678
return await xdr.ReadDecimalAsync(field.DataType, field.NumericScale, cancellationToken).ConfigureAwait(false);
16711679

16721680
case DbDataType.Float:

‎src/FirebirdSql.Data.FirebirdClient/Client/Native/FesStatement.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ public override void Execute(int timeout, IDescriptorFiller descriptorFiller)
413413
for (var i = 0; i < values.Length; i++)
414414
{
415415
var d = descriptor[i];
416-
var value = d.DbValue.GetValue();
416+
var value = d.DbValue.GetDecodedValue();
417417
values[i] = new DbValue(this, d, value);
418418
}
419419

@@ -485,7 +485,7 @@ public override async ValueTask ExecuteAsync(int timeout, IDescriptorFiller desc
485485
for (var i = 0; i < values.Length; i++)
486486
{
487487
var d = descriptor[i];
488-
var value = await d.DbValue.GetValueAsync(cancellationToken).ConfigureAwait(false);
488+
var value = await d.DbValue.GetDecodedValueAsync(cancellationToken).ConfigureAwait(false);
489489
values[i] = new DbValue(this, d, value);
490490
}
491491

@@ -573,7 +573,7 @@ public override DbValue[] Fetch()
573573
for (var i = 0; i < row.Length; i++)
574574
{
575575
var d = _fields[i];
576-
var value = d.DbValue.GetValue();
576+
var value = d.DbValue.GetDecodedValue();
577577
row[i] = new DbValue(this, d, value);
578578
}
579579
return row;
@@ -643,7 +643,7 @@ public override async ValueTask<DbValue[]> FetchAsync(CancellationToken cancella
643643
for (var i = 0; i < row.Length; i++)
644644
{
645645
var d = _fields[i];
646-
var value = await d.DbValue.GetValueAsync(cancellationToken).ConfigureAwait(false);
646+
var value = await d.DbValue.GetDecodedValueAsync(cancellationToken).ConfigureAwait(false);
647647
row[i] = new DbValue(this, d, value);
648648
}
649649
return row;

0 commit comments

Comments
 (0)