diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f49a41d9..e67486e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,7 @@ jobs: ports: [ "2135:2135", "2136:2136", "8765:8765" ] env: YDB_LOCAL_SURVIVE_RESTART: true + YDB_FEATURE_FLAGS: enable_parameterized_decimal options: '--name ydb-local -h localhost' steps: diff --git a/src/Ydb.Sdk/CHANGELOG.md b/src/Ydb.Sdk/CHANGELOG.md index 8e8779ea..aa22338b 100644 --- a/src/Ydb.Sdk/CHANGELOG.md +++ b/src/Ydb.Sdk/CHANGELOG.md @@ -1,4 +1,5 @@ -- Fixed bug: interval value parsing in microseconds and double instead of ticks. +- Feat ADO.NET: decimal type with arbitrary precision/scale ([#498](https://github.com/ydb-platform/ydb-dotnet-sdk/issues/498)). +- Fixed bug: interval value parsing in microseconds and double instead of ticks ([#497](https://github.com/ydb-platform/ydb-dotnet-sdk/issues/497)). - ADO.NET: Changed `IBulkUpsertImporter.AddRowAsync` signature: `object?[] row` → `params object[]`. ## v0.21.0 diff --git a/src/Ydb.Sdk/src/Ado/Schema/SchemaUtils.cs b/src/Ydb.Sdk/src/Ado/Schema/SchemaUtils.cs index 623924a7..476903db 100644 --- a/src/Ydb.Sdk/src/Ado/Schema/SchemaUtils.cs +++ b/src/Ydb.Sdk/src/Ado/Schema/SchemaUtils.cs @@ -12,7 +12,7 @@ internal static class SchemaUtils Type.Types.PrimitiveTypeId.String => "Bytes", _ => type.TypeId.ToString() }, - Type.TypeOneofCase.DecimalType => "Decimal(22, 9)", + Type.TypeOneofCase.DecimalType => $"Decimal({type.DecimalType.Precision}, {type.DecimalType.Scale})", _ => "Unknown" }; diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index f6ede048..fb0b56d6 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -2,6 +2,7 @@ using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; +using Google.Protobuf.WellKnownTypes; using Ydb.Sdk.Value; namespace Ydb.Sdk.Ado; @@ -30,8 +31,6 @@ public sealed class YdbParameter : DbParameter { DbType.Byte, YdbValue.MakeOptionalUint8() }, { DbType.DateTime2, YdbValue.MakeOptionalTimestamp() }, { DbType.DateTimeOffset, YdbValue.MakeOptionalTimestamp() }, - { DbType.Decimal, YdbValue.MakeOptionalDecimal() }, - { DbType.Currency, YdbValue.MakeOptionalDecimal() }, { DbType.Guid, YdbValue.MakeOptionalUuid() } }; @@ -93,10 +92,23 @@ public override string SourceColumn public override bool SourceColumnNullMapping { get; set; } public override int Size { get; set; } + public override byte Precision { get; set; } + public override byte Scale { get; set; } + internal YdbValue YdbValue => Value switch { YdbValue ydbValue => ydbValue, null or DBNull when YdbNullByDbType.TryGetValue(DbType, out var value) => value, + null or DBNull when DbType is DbType.Decimal or DbType.Currency => Precision == 0 && Scale == 0 + ? YdbValue.MakeOptionalDecimal() + : new YdbValue( + new Type + { + OptionalType = new OptionalType + { Item = new Type { DecimalType = new DecimalType { Precision = Precision, Scale = Scale } } } + }, + new Ydb.Value { NullFlagValue = new NullValue() } + ), string valueString when DbType is DbType.String or DbType.AnsiString or DbType.AnsiStringFixedLength or DbType.StringFixedLength or DbType.Object => YdbValue.MakeUtf8(valueString), bool boolValue when DbType is DbType.Boolean or DbType.Object => YdbValue.MakeBool(boolValue), @@ -126,7 +138,9 @@ string valueString when DbType is DbType.String or DbType.AnsiString or DbType.A }, long longValue when DbType is DbType.Int64 or DbType.Object => YdbValue.MakeInt64(longValue), decimal decimalValue when DbType is DbType.Decimal or DbType.Currency or DbType.Object => - YdbValue.MakeDecimal(decimalValue), + Precision == 0 && Scale == 0 + ? YdbValue.MakeDecimal(decimalValue) + : YdbValue.MakeDecimalWithPrecision(decimalValue, precision: Precision, scale: Scale), ulong ulongValue when DbType is DbType.UInt64 or DbType.Object => YdbValue.MakeUint64(ulongValue), uint uintValue => DbType switch { diff --git a/src/Ydb.Sdk/src/Value/YdbValue.cs b/src/Ydb.Sdk/src/Value/YdbValue.cs index 287017fa..bc2131a1 100644 --- a/src/Ydb.Sdk/src/Value/YdbValue.cs +++ b/src/Ydb.Sdk/src/Value/YdbValue.cs @@ -75,7 +75,7 @@ private static string ToYql(Type type) => type.TypeCase switch { Type.TypeOneofCase.TypeId => type.TypeId.ToString(), - Type.TypeOneofCase.DecimalType => "Decimal(22, 9)", + Type.TypeOneofCase.DecimalType => $"Decimal({type.DecimalType.Precision}, {type.DecimalType.Scale})", Type.TypeOneofCase.OptionalType => $"{ToYql(type.OptionalType.Item)}?", Type.TypeOneofCase.ListType => $"List<{ToYql(type.ListType.Item)}>", Type.TypeOneofCase.VoidType => "Void", diff --git a/src/Ydb.Sdk/src/Value/YdbValueBuilder.cs b/src/Ydb.Sdk/src/Value/YdbValueBuilder.cs index 71a1dfce..532aacf4 100644 --- a/src/Ydb.Sdk/src/Value/YdbValueBuilder.cs +++ b/src/Ydb.Sdk/src/Value/YdbValueBuilder.cs @@ -86,86 +86,33 @@ public static YdbValue MakeUuid(Guid guid) new Ydb.Value { Low128 = low, High128 = high }); } - private static byte GetDecimalScale(decimal value) + public static YdbValue MakeDecimalWithPrecision(decimal value, uint precision, uint scale) { - var bits = decimal.GetBits(value); - var flags = bits[3]; - var scale = (byte)((flags >> 16) & 0x7F); - return scale; - } - - private static uint GetDecimalPrecision(decimal value) - { - var bits = decimal.GetBits(value); - value = new decimal(lo: bits[0], mid: bits[1], hi: bits[2], isNegative: false, scale: 0); - - var precision = 0u; - while (value != decimal.Zero) - { - value = Math.Round(value / 10); - precision++; - } - - return precision; - } + value *= 1.00000000000000000000000000000m; // 29 zeros, max supported by c# decimal + value = Math.Round(value, (int)scale); - private static Ydb.Value MakeDecimalValue(decimal value) - { + var type = new Type { DecimalType = new DecimalType { Scale = scale, Precision = precision } }; var bits = decimal.GetBits(value); - - var low64 = ((ulong)(uint)bits[1] << 32) + (uint)bits[0]; - var high64 = (ulong)(uint)bits[2]; + var lo = ((ulong)bits[1] << 32) + (uint)bits[0]; + var hi = (ulong)bits[2]; unchecked { - // make value negative if (value < 0) { - low64 = ~low64; - high64 = ~high64; + lo = ~lo; + hi = ~hi; - if (low64 == (ulong)-1L) + if (lo == (ulong)-1L) { - high64 += 1; + hi += 1; } - low64 += 1; + lo += 1; } } - return new Ydb.Value - { - Low128 = low64, - High128 = high64 - }; - } - - public static YdbValue MakeDecimalWithPrecision(decimal value, uint? precision = null, uint? scale = null) - { - var valueScale = GetDecimalScale(value); - var valuePrecision = GetDecimalPrecision(value); - scale ??= GetDecimalScale(value); - precision ??= valuePrecision; - - if ((int)valuePrecision - valueScale > (int)precision - scale) - { - throw new InvalidCastException( - $"Decimal with precision ({valuePrecision}, {valueScale}) can't fit into ({precision}, {scale})"); - } - - // multiply for fill value with trailing zeros - // ex: 123.45 -> 123.4500...00 - value *= 1.00000000000000000000000000000m; // 29 zeros, max supported by c# decimal - value = Math.Round(value, (int)scale); - - var type = new Type - { - DecimalType = new DecimalType { Scale = (uint)scale, Precision = (uint)precision } - }; - - var ydbValue = MakeDecimalValue(value); - - return new YdbValue(type, ydbValue); + return new YdbValue(type, new Ydb.Value { Low128 = lo, High128 = hi }); } public static YdbValue MakeDecimal(decimal value) => MakeDecimalWithPrecision(value, 22, 9); @@ -175,7 +122,8 @@ private static YdbValue MakeOptional(YdbValue value) => new Type { OptionalType = new OptionalType { Item = value._protoType } }, value.TypeId != YdbTypeId.OptionalType ? value._protoValue - : new Ydb.Value { NestedValue = value._protoValue }); + : new Ydb.Value { NestedValue = value._protoValue } + ); // TODO: MakeEmptyList with complex types public static YdbValue MakeEmptyList(YdbTypeId typeId) => diff --git a/src/Ydb.Sdk/src/Value/YdbValueParser.cs b/src/Ydb.Sdk/src/Value/YdbValueParser.cs index 72a528ea..51ed43e9 100644 --- a/src/Ydb.Sdk/src/Value/YdbValueParser.cs +++ b/src/Ydb.Sdk/src/Value/YdbValueParser.cs @@ -150,31 +150,28 @@ public Guid GetUuid() public decimal GetDecimal() { EnsureType(Type.TypeOneofCase.DecimalType); - var low64 = _protoValue.Low128; - var high64 = _protoValue.High128; - + var lo = _protoValue.Low128; + var hi = _protoValue.High128; var scale = _protoType.DecimalType.Scale; - - var isNegative = false; + var isNegative = (hi & 0x8000_0000_0000_0000UL) != 0; unchecked { - if (high64 >> 63 == 1) // if negative + if (isNegative) { - isNegative = true; - if (low64 == 0) - { - high64 -= 1; - } + if (lo == 0) + hi--; - low64 -= 1; - - low64 = ~low64; - high64 = ~high64; + lo--; + lo = ~lo; + hi = ~hi; } } - return new decimal((int)low64, (int)(low64 >> 32), (int)high64, isNegative, (byte)scale); + if (hi >> 32 != 0) + throw new OverflowException("Value does not fit into decimal"); + + return new decimal((int)lo, (int)(lo >> 32), (int)hi, isNegative, (byte)scale); } public bool? GetOptionalBool() => GetOptional()?.GetBool(); diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbValueUnitTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbValueUnitTests.cs index 688366cf..9ca9be0c 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbValueUnitTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbValueUnitTests.cs @@ -274,34 +274,23 @@ public void DecimalType() Assert.Null(YdbValue.MakeOptionalDecimal().GetOptionalDecimal()); Assert.Null((decimal?)(YdbValue)(decimal?)null); - - Assert.Equal("Decimal with precision (30, 0) can't fit into (22, 9)", - Assert.Throws(() => YdbValue.MakeDecimal(decimal.MaxValue)).Message); } [Fact] public void DecimalTypeWithPrecision() { - Assert.Equal(12345m, YdbValue.MakeDecimalWithPrecision(12345m).GetDecimal()); + Assert.Equal(12345m, YdbValue.MakeDecimal(12345m).GetDecimal()); Assert.Equal(12345m, YdbValue.MakeDecimalWithPrecision(12345m, precision: 5, scale: 0).GetDecimal()); Assert.Equal(12345m, YdbValue.MakeDecimalWithPrecision(12345m, precision: 7, scale: 2).GetDecimal()); Assert.Equal(123.46m, YdbValue.MakeDecimalWithPrecision(123.456m, precision: 5, scale: 2).GetDecimal()); Assert.Equal(-18446744073.709551616m, - YdbValue.MakeDecimalWithPrecision(-18446744073.709551616m).GetDecimal()); + YdbValue.MakeDecimal(-18446744073.709551616m).GetDecimal()); Assert.Equal(-18446744073.709551616m, YdbValue.MakeDecimalWithPrecision(-18446744073.709551616m, precision: 21, scale: 9).GetDecimal()); Assert.Equal(-18446744074m, YdbValue.MakeDecimalWithPrecision(-18446744073.709551616m, precision: 12, scale: 0).GetDecimal()); Assert.Equal(-184467440730709551616m, YdbValue.MakeDecimalWithPrecision(-184467440730709551616m, precision: 21, scale: 0).GetDecimal()); - - - Assert.Equal("Decimal with precision (5, 0) can't fit into (4, 0)", - Assert.Throws(() => YdbValue.MakeDecimalWithPrecision(12345m, precision: 4)) - .Message); - Assert.Equal("Decimal with precision (5, 0) can't fit into (5, 2)", - Assert.Throws(() => YdbValue.MakeDecimalWithPrecision(12345m, precision: 5, 2)) - .Message); } [Fact] diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbCommandTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbCommandTests.cs index 649da8fd..5c3ebb14 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbCommandTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbCommandTests.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Globalization; using System.Text; using Xunit; using Ydb.Sdk.Value; @@ -470,4 +471,44 @@ public async Task Date_WhenSetDateOnly_ReturnDateTime() ydbCommand.Parameters.AddWithValue("dateOnly", DbType.Date, new DateOnly(2102, 2, 24)); Assert.Equal(new DateTime(2102, 2, 24), await ydbCommand.ExecuteScalarAsync()); } + + [Theory] + [InlineData("12345", "12345.0000000000", 22, 9)] + [InlineData("54321", "54321", 5, 0)] + [InlineData("493235.4", "493235.40", 7, 2)] + [InlineData("123.46", "123.46", 5, 2)] + [InlineData("-184467434073.70911616", "-184467434073.7091161600", 35, 10)] + [InlineData("-18446744074", "-18446744074", 12, 0)] + [InlineData("-184467440730709551616", "-184467440730709551616", 21, 0)] + [InlineData("-218446744073.709551616", "-218446744073.7095516160", 22, 10)] + [InlineData(null, null, 22, 9)] + [InlineData(null, null, 35, 9)] + [InlineData(null, null, 35, 0)] + public async Task Decimal_WhenDecimalIsScaleAndPrecision_ReturnDecimal(string? value, string? expected, + byte precision, byte scale) + { + await using var ydbConnection = await CreateOpenConnectionAsync(); + var decimalTableName = $"DecimalTable_{Random.Shared.Next()}"; + var decimalValue = value == null ? (decimal?)null : decimal.Parse(value, CultureInfo.InvariantCulture); + var ydbCommand = new YdbCommand(ydbConnection) + { + CommandText = $""" + CREATE TABLE {decimalTableName} ( + DecimalField Decimal({precision}, {scale}), + PRIMARY KEY (DecimalField) + ) + """ + }; + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = $"INSERT INTO {decimalTableName}(DecimalField) VALUES (@DecimalField);"; + ydbCommand.Parameters.Add( + new YdbParameter("DecimalField", DbType.Decimal, decimalValue) + { Precision = precision, Scale = scale }); + await ydbCommand.ExecuteNonQueryAsync(); + + ydbCommand.CommandText = $"SELECT DecimalField FROM {decimalTableName}"; + Assert.Equal(expected == null ? DBNull.Value : decimal.Parse(expected, CultureInfo.InvariantCulture), + await ydbCommand.ExecuteScalarAsync()); + ydbCommand.CommandText = "DROP TABLE {decimalTableName};"; + } }