diff --git a/src/Ydb.Sdk/CHANGELOG.md b/src/Ydb.Sdk/CHANGELOG.md index b44c4670..d57acc38 100644 --- a/src/Ydb.Sdk/CHANGELOG.md +++ b/src/Ydb.Sdk/CHANGELOG.md @@ -1,3 +1,4 @@ +- Dev: Parameterized Decimal overflow check (precision/scale). - Feat: Implement `YdbRetryPolicy` with AWS-inspired Exponential Backoff and Jitter. - Dev: LogLevel `Warning` -> `Debug` on DeleteSession has been `RpcException`. diff --git a/src/Ydb.Sdk/src/Ado/YdbType/YdbTypedValueExtensions.cs b/src/Ydb.Sdk/src/Ado/YdbType/YdbTypedValueExtensions.cs index 3ec71829..c05721fe 100644 --- a/src/Ydb.Sdk/src/Ado/YdbType/YdbTypedValueExtensions.cs +++ b/src/Ydb.Sdk/src/Ado/YdbType/YdbTypedValueExtensions.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Numerics; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; @@ -73,28 +75,33 @@ internal static TypedValue Double(this double value) => internal static TypedValue Decimal(this decimal value, byte precision, byte scale) { - value *= 1.00000000000000000000000000000m; // 29 zeros, max supported by c# decimal - value = Math.Round(value, scale); - var bits = decimal.GetBits(value); - var low = ((ulong)bits[1] << 32) + (uint)bits[0]; - var high = (ulong)bits[2]; + var scale0 = (bits[3] >> 16) & 0xFF; + var isNegative = (bits[3] & unchecked((int)0x80000000)) != 0; - unchecked - { - if (value < 0) - { - low = ~low; - high = ~high; + if (scale0 > scale) + throw new OverflowException( + $"Decimal scale overflow: fractional digits {scale0} exceed allowed {scale} for DECIMAL({precision},{scale}). Value={value}"); - if (low == (ulong)-1L) - { - high += 1; - } + var lo = (uint)bits[0]; + var mid = (uint)bits[1]; + var hi = (uint)bits[2]; + var mantissa = ((BigInteger)hi << 64) | ((BigInteger)mid << 32) | lo; - low += 1; - } - } + var delta = scale - scale0; + if (delta > 0) + mantissa *= BigInteger.Pow(10, delta); + + var totalDigits = mantissa.IsZero ? 1 : mantissa.ToString(CultureInfo.InvariantCulture).Length; + if (totalDigits > precision) + throw new OverflowException( + $"Decimal precision overflow: total digits {totalDigits} exceed allowed {precision} for DECIMAL({precision},{scale}). Value={value}"); + + if (isNegative) mantissa = -mantissa; + + var mod128 = (mantissa % (BigInteger.One << 128) + (BigInteger.One << 128)) % (BigInteger.One << 128); + var low = (ulong)(mod128 & ((BigInteger.One << 64) - 1)); + var high = (ulong)(mod128 >> 64); return new TypedValue { diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbParameterTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbParameterTests.cs index 75be4e27..6d01d32a 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbParameterTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbParameterTests.cs @@ -187,7 +187,7 @@ public async Task Date_WhenSetDateOnly_ReturnDateTime() [Theory] [InlineData("12345", "12345.0000000000", 22, 9)] [InlineData("54321", "54321", 5, 0)] - [InlineData("493235.4", "493235.40", 7, 2)] + [InlineData("493235.4", "493235.40", 8, 2)] [InlineData("123.46", "123.46", 5, 2)] [InlineData("-184467434073.70911616", "-184467434073.7091161600", 35, 10)] [InlineData("-18446744074", "-18446744074", 12, 0)] @@ -227,6 +227,87 @@ PRIMARY KEY (DecimalField) await new YdbCommand(ydbConnection) { CommandText = $"DROP TABLE {decimalTableName};" }.ExecuteNonQueryAsync(); } + [Fact] + public async Task Decimal_WhenFractionalDigitsExceedScale_Throws() + { + await using var ydb = await CreateOpenConnectionAsync(); + var t = $"T_{Random.Shared.Next()}"; + await new YdbCommand(ydb) { CommandText = $"CREATE TABLE {t}(d Decimal(5,2), PRIMARY KEY(d))" } + .ExecuteNonQueryAsync(); + + var cmd = new YdbCommand(ydb) + { + CommandText = $"INSERT INTO {t}(d) VALUES (@d);", + Parameters = { new YdbParameter("d", DbType.Decimal, 123.456m) { Precision = 5, Scale = 2 } } + }; + + await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + } + + [Fact] + public async Task Decimal_WhenIntegerDigitsExceedPrecisionMinusScale_Throws() + { + await using var ydb = await CreateOpenConnectionAsync(); + var t = $"T_{Random.Shared.Next()}"; + await new YdbCommand(ydb) { CommandText = $"CREATE TABLE {t}(d Decimal(5,0), PRIMARY KEY(d))" } + .ExecuteNonQueryAsync(); + + var cmd = new YdbCommand(ydb) + { + CommandText = $"INSERT INTO {t}(d) VALUES (@d);", + Parameters = { new YdbParameter("d", DbType.Decimal, 100000m) { Precision = 5, Scale = 0 } } + }; + + await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + } + + [Fact] + public async Task Decimal_WhenScaleGreaterThanPrecision_ThrowsByMathNotByIf() + { + await using var ydb = await CreateOpenConnectionAsync(); + var t = $"T_{Random.Shared.Next()}"; + await new YdbCommand(ydb) { CommandText = $"CREATE TABLE {t}(d Decimal(5,4), PRIMARY KEY(d))" } + .ExecuteNonQueryAsync(); + + var cmd = new YdbCommand(ydb) + { + CommandText = $"INSERT INTO {t}(d) VALUES (@d);", + Parameters = { new YdbParameter("d", DbType.Decimal, 0.0m) { Precision = 1, Scale = 2 } } + }; + + await Assert.ThrowsAnyAsync(() => cmd.ExecuteNonQueryAsync()); + } + + [Fact] + public async Task Decimal_WhenYdbReturnsDecimal35_0_OverflowsDotNetDecimal() + { + await using var ydb = await CreateOpenConnectionAsync(); + var t = $"T_{Random.Shared.Next()}"; + await new YdbCommand(ydb) { CommandText = $"CREATE TABLE {t}(d Decimal(35,0), PRIMARY KEY(d))" } + .ExecuteNonQueryAsync(); + + await new YdbCommand(ydb) + { + CommandText = $@"INSERT INTO {t}(d) VALUES (CAST('10000000000000000000000000000000000' AS Decimal(35,0)));" + }.ExecuteNonQueryAsync(); + + var select = new YdbCommand(ydb) { CommandText = $"SELECT d FROM {t};" }; + + await Assert.ThrowsAsync(() => select.ExecuteScalarAsync()); + } + + [Fact] + public void Decimal_WhenEncodingP35_10_With25IntegerDigits_DoesNotOverflow() + { + var val = decimal.Parse("1234567890123456789012345", CultureInfo.InvariantCulture); + var param = new YdbParameter("d", DbType.Decimal, val) { Precision = 35, Scale = 10 }; + + var tv = param.TypedValue; + + Assert.Equal((byte)35, tv.Type.DecimalType.Precision); + Assert.Equal((byte)10, tv.Type.DecimalType.Scale); + } + [Fact] public async Task YdbParameter_WhenYdbDbTypeSetAndValueIsNull_ReturnsNullValue() {