Skip to content

Commit 531b609

Browse files
feat: enforce DECIMAL(p,s) overflow in parameters (#519)
Co-authored-by: bogdangalka <[email protected]>
1 parent ce6b36c commit 531b609

File tree

4 files changed

+147
-41
lines changed

4 files changed

+147
-41
lines changed

src/Ydb.Sdk/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
- Fix bug wrap-around ADO.NET: Big parameterized Decimal — `((ulong)bits[1] << 32)` -> `((ulong)(uint)bits[1] << 32)`
2+
- Feat ADO.NET: Parameterized Decimal overflow check: `Precision` and `Scale`.
13
- Feat ADO.NET: Deleted support for `DateTimeOffset` was a mistake.
24
- Feat ADO.NET: Added support for `Date32`, `Datetime64`, `Timestamp64` and `Interval64` types in YDB.
35
- Feat: Implement `YdbRetryPolicy` with AWS-inspired Exponential Backoff and Jitter.

src/Ydb.Sdk/src/Ado/Internal/YdbTypedValueExtensions.cs

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ namespace Ydb.Sdk.Ado.Internal;
55

66
internal static class YdbTypedValueExtensions
77
{
8+
private const byte MaxPrecisionDecimal = 29;
9+
private static readonly decimal[] Pow10 = CreatePow10();
10+
11+
private static decimal[] CreatePow10()
12+
{
13+
var a = new decimal[29];
14+
a[0] = 1m;
15+
for (var i = 1; i < a.Length; i++) a[i] = a[i - 1] * 10m; // 1..1e28
16+
return a;
17+
}
18+
819
internal static TypedValue Null(this Type.Types.PrimitiveTypeId primitiveTypeId) => new()
920
{
1021
Type = new Type { OptionalType = new OptionalType { Item = new Type { TypeId = primitiveTypeId } } },
@@ -73,26 +84,29 @@ internal static TypedValue Double(this double value) =>
7384

7485
internal static TypedValue Decimal(this decimal value, byte precision, byte scale)
7586
{
76-
value *= 1.00000000000000000000000000000m; // 29 zeros, max supported by c# decimal
77-
value = Math.Round(value, scale);
87+
if (scale > precision)
88+
throw new ArgumentOutOfRangeException(nameof(scale), "Scale cannot exceed precision");
89+
90+
var origScale = (decimal.GetBits(value)[3] >> 16) & 0xFF;
7891

92+
if (origScale > scale || (precision < MaxPrecisionDecimal && Pow10[precision - scale] <= Math.Abs(value)))
93+
{
94+
throw new OverflowException($"Value {value} does not fit Decimal({precision}, {scale})");
95+
}
96+
97+
value *= 1.0000000000000000000000000000m; // 28 zeros, max supported by c# decimal
98+
value = Math.Round(value, scale);
7999
var bits = decimal.GetBits(value);
80-
var low = ((ulong)bits[1] << 32) + (uint)bits[0];
81-
var high = (ulong)bits[2];
100+
var low = ((ulong)(uint)bits[1] << 32) | (uint)bits[0];
101+
var high = (ulong)(uint)bits[2];
102+
var isNegative = bits[3] < 0;
82103

83104
unchecked
84105
{
85-
if (value < 0)
106+
if (isNegative)
86107
{
87-
low = ~low;
88-
high = ~high;
89-
90-
if (low == (ulong)-1L)
91-
{
92-
high += 1;
93-
}
94-
95-
low += 1;
108+
low = ~low + 1UL;
109+
high = ~high + (low == 0 ? 1UL : 0UL);
96110
}
97111
}
98112

src/Ydb.Sdk/src/Ado/Internal/YdbValueExtensions.cs

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,21 @@ internal static Guid GetUuid(this Ydb.Value value)
7777

7878
internal static decimal GetDecimal(this Ydb.Value value, uint scale)
7979
{
80-
var lo = value.Low128;
81-
var hi = value.High128;
82-
var isNegative = (hi & 0x8000_0000_0000_0000UL) != 0;
80+
var low = value.Low128;
81+
var high = value.High128;
82+
var isNegative = (high & 0x8000_0000_0000_0000UL) != 0;
8383
unchecked
8484
{
8585
if (isNegative)
8686
{
87-
if (lo == 0)
88-
hi--;
89-
90-
lo--;
91-
lo = ~lo;
92-
hi = ~hi;
87+
low = ~low + 1UL;
88+
high = ~high + (low == 0 ? 1UL : 0UL);
9389
}
9490
}
9591

96-
if (hi >> 32 != 0)
92+
if (high >> 32 != 0)
9793
throw new OverflowException("Value does not fit into decimal");
9894

99-
return new decimal((int)lo, (int)(lo >> 32), (int)hi, isNegative, (byte)scale);
95+
return new decimal((int)low, (int)(low >> 32), (int)high, isNegative, (byte)scale);
10096
}
10197
}

src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbParameterTests.cs

Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,44 +148,138 @@ public async Task Date_WhenSetDateOnly_ReturnDateTime()
148148
[Theory]
149149
[InlineData("12345", "12345.0000000000", 22, 9)]
150150
[InlineData("54321", "54321", 5, 0)]
151-
[InlineData("493235.4", "493235.40", 7, 2)]
151+
[InlineData("493235.4", "493235.40", 8, 2)]
152152
[InlineData("123.46", "123.46", 5, 2)]
153+
[InlineData("0.46", "0.46", 2, 2)]
153154
[InlineData("-184467434073.70911616", "-184467434073.7091161600", 35, 10)]
154155
[InlineData("-18446744074", "-18446744074", 12, 0)]
155156
[InlineData("-184467440730709551616", "-184467440730709551616", 21, 0)]
156157
[InlineData("-218446744073.709551616", "-218446744073.7095516160", 22, 10)]
158+
[InlineData("79228162514264337593543950335", "79228162514264337593543950335", 29, 0)]
159+
[InlineData("79228162514264337593543950.335", "79228162514264337593543950.335", 29, 3)]
160+
[InlineData("-79228162514264337593543950335", "-79228162514264337593543950335", 29, 0)]
161+
[InlineData("-79228162514264337593543950.335", "-79228162514264337593543950.335", 29, 3)]
157162
[InlineData(null, null, 22, 9)]
158163
[InlineData(null, null, 35, 9)]
159164
[InlineData(null, null, 35, 0)]
160165
public async Task Decimal_WhenDecimalIsScaleAndPrecision_ReturnDecimal(string? value, string? expected,
161166
byte precision, byte scale)
162167
{
163168
await using var ydbConnection = await CreateOpenConnectionAsync();
164-
var decimalTableName = $"DecimalTable_{Random.Shared.Next()}";
169+
var tableName = $"DecimalTable_{Random.Shared.Next()}";
165170
var decimalValue = value == null ? (decimal?)null : decimal.Parse(value, CultureInfo.InvariantCulture);
166171
await new YdbCommand(ydbConnection)
167-
{
168-
CommandText = $"""
169-
CREATE TABLE {decimalTableName} (
170-
DecimalField Decimal({precision}, {scale}),
171-
PRIMARY KEY (DecimalField)
172-
)
173-
"""
174-
}.ExecuteNonQueryAsync();
172+
{ CommandText = $"CREATE TABLE {tableName} (d Decimal({precision}, {scale}), PRIMARY KEY (d))" }
173+
.ExecuteNonQueryAsync();
175174
await new YdbCommand(ydbConnection)
176175
{
177-
CommandText = $"INSERT INTO {decimalTableName}(DecimalField) VALUES (@DecimalField);",
176+
CommandText = $"INSERT INTO {tableName}(d) VALUES (@d);",
178177
Parameters =
179-
{
180-
new YdbParameter("DecimalField", DbType.Decimal, decimalValue) { Precision = precision, Scale = scale }
181-
}
178+
{ new YdbParameter("d", DbType.Decimal, decimalValue) { Precision = precision, Scale = scale } }
182179
}.ExecuteNonQueryAsync();
183180

184181
Assert.Equal(expected == null ? DBNull.Value : decimal.Parse(expected, CultureInfo.InvariantCulture),
185-
await new YdbCommand(ydbConnection) { CommandText = $"SELECT DecimalField FROM {decimalTableName};" }
182+
await new YdbCommand(ydbConnection) { CommandText = $"SELECT d FROM {tableName};" }
186183
.ExecuteScalarAsync());
187184

188-
await new YdbCommand(ydbConnection) { CommandText = $"DROP TABLE {decimalTableName};" }.ExecuteNonQueryAsync();
185+
await new YdbCommand(ydbConnection) { CommandText = $"DROP TABLE {tableName};" }.ExecuteNonQueryAsync();
186+
}
187+
188+
[Theory]
189+
[InlineData("123.456", 5, 2)]
190+
[InlineData("1.46", 2, 2)]
191+
[InlineData("654321", 5, 0)]
192+
[InlineData("493235.4", 7, 2)]
193+
[InlineData("10.46", 3, 2)]
194+
[InlineData("99.999", 5, 2)]
195+
[InlineData("0.001", 3, 2)]
196+
[InlineData("-12.345", 5, 2)]
197+
[InlineData("7.001", 4, 2)]
198+
[InlineData("1.0001", 5, 3)]
199+
[InlineData("1000.00", 5, 2)]
200+
[InlineData("123456.7", 6, 1)]
201+
[InlineData("999.99", 5, 4)]
202+
[InlineData("-100", 2, 0)]
203+
[InlineData("-0.12", 2, 1)]
204+
[InlineData("10.0", 2, 0)]
205+
[InlineData("-0.1", 1, 0)]
206+
[InlineData("10000", 4, 0)]
207+
[InlineData("12345", 4, 0)]
208+
[InlineData("12.3456", 6, 3)]
209+
[InlineData("123.45", 4, 1)]
210+
[InlineData("9999.9", 5, 0)]
211+
[InlineData("-1234.56", 5, 1)]
212+
[InlineData("-1000", 3, 0)]
213+
[InlineData("0.0001", 4, 3)]
214+
[InlineData("99999", 4, 0)]
215+
[InlineData("9.999", 3, 2)]
216+
[InlineData("123.4", 3, 0)]
217+
[InlineData("1.234", 4, 2)]
218+
[InlineData("-98.765", 5, 2)]
219+
[InlineData("100.01", 5, 1)]
220+
[InlineData("100000", 5, 0)]
221+
public async Task Decimal_WhenNotRepresentableBySystemDecimal_ThrowsOverflowException(string value, byte precision,
222+
byte scale)
223+
{
224+
await using var ydbConnection = await CreateOpenConnectionAsync();
225+
var tableName = $"DecimalOverflowTable__{Random.Shared.Next()}";
226+
var decimalValue = decimal.Parse(value, CultureInfo.InvariantCulture);
227+
await new YdbCommand(ydbConnection)
228+
{ CommandText = $"CREATE TABLE {tableName}(d Decimal(5,2), PRIMARY KEY(d))" }
229+
.ExecuteNonQueryAsync();
230+
231+
Assert.Equal($"Value {decimalValue} does not fit Decimal({precision}, {scale})",
232+
(await Assert.ThrowsAsync<OverflowException>(() => new YdbCommand(ydbConnection)
233+
{
234+
CommandText = $"INSERT INTO {tableName}(d) VALUES (@d);",
235+
Parameters =
236+
{
237+
new YdbParameter("d", DbType.Decimal, 123.456m)
238+
{ Value = decimalValue, Precision = precision, Scale = scale }
239+
}
240+
}.ExecuteNonQueryAsync())).Message);
241+
242+
Assert.Equal(0ul,
243+
(ulong)(await new YdbCommand(ydbConnection) { CommandText = $"SELECT COUNT(*) FROM {tableName};" }
244+
.ExecuteScalarAsync())!
245+
);
246+
247+
await new YdbCommand(ydbConnection) { CommandText = $"DROP TABLE {tableName};" }.ExecuteNonQueryAsync();
248+
}
249+
250+
251+
[Fact]
252+
public void Decimal_WhenScaleGreaterThanPrecision_ThrowsArgumentOutOfRangeException() =>
253+
Assert.Throws<ArgumentOutOfRangeException>(() =>
254+
new YdbParameter("d", DbType.Decimal, 0.0m) { Precision = 1, Scale = 2 }.TypedValue);
255+
256+
[Theory]
257+
[InlineData("10000000000000000000000000000000000", 35, 0)]
258+
[InlineData("1000000000000000000000000.0000000000", 35, 10)]
259+
[InlineData("1000000000000000000000000000000000", 34, 0)]
260+
[InlineData("100000000000000000000000.0000000000", 34, 10)]
261+
[InlineData("100000000000000000000000000000000", 33, 0)]
262+
[InlineData("10000000000000000000000.0000000000", 33, 10)]
263+
[InlineData("-10000000000000000000000000000000", 32, 0)]
264+
[InlineData("-1000000000000000000000.0000000000", 32, 10)]
265+
[InlineData("-1000000000000000000000000000000", 31, 0)]
266+
[InlineData("-100000000000000000000.0000000000", 31, 10)]
267+
[InlineData("1000000000000000000000000000000", 30, 0)]
268+
[InlineData("100000000000000000000.0000000000", 30, 10)]
269+
[InlineData("79228162514264337593543950336", 29, 0)]
270+
[InlineData("79228162514264337593543950.336", 29, 3)]
271+
[InlineData("-79228162514264337593543950336", 29, 0)]
272+
[InlineData("-79228162514264337593543950.336", 29, 3)]
273+
[InlineData("100000", 4, 0)] // inf
274+
public async Task Decimal_WhenYdbReturnsDecimalWithPrecisionGreaterThan28_ThrowsOverflowException(string value,
275+
int precision, int scale)
276+
{
277+
await using var ydbConnection = await CreateOpenConnectionAsync();
278+
Assert.Equal("Value does not fit into decimal", (await Assert.ThrowsAsync<OverflowException>(() =>
279+
new YdbCommand(ydbConnection)
280+
{ CommandText = $"SELECT (CAST('{value}' AS Decimal({precision}, {scale})));" }
281+
.ExecuteScalarAsync())
282+
).Message);
189283
}
190284

191285
[Fact]

0 commit comments

Comments
 (0)