diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c7b01fa0..aa4887be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,7 @@ jobs: ports: [ "2135:2135", "2136:2136", "8765:8765" ] env: YDB_LOCAL_SURVIVE_RESTART: true + YDB_FEATURE_FLAGS: enable_parameterized_decimal,enable_table_datetime64 options: '--name ydb-local -h localhost' steps: diff --git a/src/EFCore.Ydb/CHANGELOG.md b/src/EFCore.Ydb/CHANGELOG.md index 991b3693..df2b9624 100644 --- a/src/EFCore.Ydb/CHANGELOG.md +++ b/src/EFCore.Ydb/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed Decimal precision/scale mapping in EF provider. - Supported Guid (Uuid YDB type). - PrivateAssets="none" is set to flow the EF Core analyzer to users referencing this package [issue](https://github.com/aspnet/EntityFrameworkCore/pull/11350). diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDecimalTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDecimalTypeMapping.cs index 50e03665..2b0d6b2b 100644 --- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDecimalTypeMapping.cs +++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDecimalTypeMapping.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Microsoft.EntityFrameworkCore.Storage; namespace EntityFrameworkCore.Ydb.Storage.Internal.Mapping; @@ -29,4 +30,15 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p protected override string ProcessStoreType( RelationalTypeMappingParameters parameters, string storeType, string storeTypeNameBase ) => $"Decimal({parameters.Precision ?? DefaultPrecision}, {parameters.Scale ?? DefaultScale})"; + + protected override void ConfigureParameter(DbParameter parameter) + { + base.ConfigureParameter(parameter); + + if (Precision is { } p) + parameter.Precision = (byte)p; + + if (Scale is { } s) + parameter.Scale = (byte)s; + } } diff --git a/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs b/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs index db2a04d6..323103b9 100644 --- a/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs +++ b/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; using System.Text.Json; @@ -13,6 +14,8 @@ public sealed class YdbTypeMappingSource( RelationalTypeMappingSourceDependencies relationalDependencies ) : RelationalTypeMappingSource(dependencies, relationalDependencies) { + private static readonly ConcurrentDictionary DecimalCache = new(); + #region Mappings private static readonly YdbBoolTypeMapping Bool = YdbBoolTypeMapping.Default; @@ -66,8 +69,6 @@ RelationalTypeMappingSourceDependencies relationalDependencies { "Float", [Float] }, { "Double", [Double] }, - { "Decimal", [Decimal] }, - { "Guid", [Guid] }, { "Date", [Date] }, @@ -97,7 +98,6 @@ RelationalTypeMappingSourceDependencies relationalDependencies { typeof(float), Float }, { typeof(double), Double }, - { typeof(decimal), Decimal }, { typeof(Guid), Guid }, @@ -111,7 +111,14 @@ RelationalTypeMappingSourceDependencies relationalDependencies }; protected override RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) - => base.FindMapping(mappingInfo) ?? FindBaseMapping(mappingInfo)?.Clone(mappingInfo); + { + if (mappingInfo.ClrType == typeof(decimal)) + { + return DecimalCache.GetOrAdd(mappingInfo, static mi => Decimal.Clone(mi)); + } + + return base.FindMapping(mappingInfo) ?? FindBaseMapping(mappingInfo)?.Clone(mappingInfo); + } private static RelationalTypeMapping? FindBaseMapping(in RelationalTypeMappingInfo mappingInfo) { diff --git a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterQueryYdbFixture.cs b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterQueryYdbFixture.cs new file mode 100644 index 00000000..fd157db6 --- /dev/null +++ b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterQueryYdbFixture.cs @@ -0,0 +1,14 @@ +using EntityFrameworkCore.Ydb.FunctionalTests.TestUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.TestUtilities; + +namespace EntityFrameworkCore.Ydb.FunctionalTests.Query; + +public class DecimalParameterQueryYdbFixture : SharedStoreFixtureBase +{ + protected override string StoreName => "DecimalParameterTest"; + + protected override ITestStoreFactory TestStoreFactory => YdbTestStoreFactory.Instance; + + public class TestContext(DbContextOptions options) : DbContext(options); +} diff --git a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTheoryTest.cs b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTheoryTest.cs new file mode 100644 index 00000000..270fd552 --- /dev/null +++ b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTheoryTest.cs @@ -0,0 +1,106 @@ +using EntityFrameworkCore.Ydb.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Xunit; + +namespace EntityFrameworkCore.Ydb.FunctionalTests.Query; + +public class DecimalParameterizedYdbTheoryTest(DecimalParameterQueryYdbFixture fixture) + : IClassFixture +{ + private DbContextOptions BuildOptions() + { + using var baseCtx = fixture.CreateContext(); + var cs = baseCtx.Database.GetDbConnection().ConnectionString; + + return new DbContextOptionsBuilder() + .UseYdb(cs) + .ReplaceService() + .Options; + } + + public static IEnumerable AdoLikeCases => + [ + [22, 9, 1.23456789m], + [30, 10, 123.4567890123m], + [18, 2, 12345678.91m], + [10, 0, 9999999999m], + [22, 9, -0.123456789m], + [5, 2, 12.34m], + [30, 10, 0.0000000001m] + ]; + + public static IEnumerable OverflowCases => + [ + [15, 2, 123456789012345.67m], + [10, 0, 12345678901m], + [22, 9, 1.0000000001m], + [18, 2, 1.239m], + [18, 2, 100000000000000000m], + [22, 9, 12345678901234567890.123456789m], + [22, 9, -12345678901234567890.123456789m], + [4, 2, 123.456m], + [1, 0, 10m], + [5, 0, 100000m] + ]; + + private ParametricDecimalContext NewCtx(int p, int s) + => new(BuildOptions(), p, s); + + private static Task DropItemsTableAsync(DbContext ctx, int p, int s) + { + var helper = ctx.GetService(); + var tableName = $"Items_{p}_{s}"; + var sql = $"DROP TABLE IF EXISTS {helper.DelimitIdentifier(tableName)}"; + + return ctx.Database.ExecuteSqlRawAsync(sql); + } + + [Theory] + [MemberData(nameof(AdoLikeCases))] + public async Task Decimal_roundtrips_or_rounds_like_ado(int p, int s, decimal value) + { + await using var ctx = NewCtx(p, s); + await ctx.Database.EnsureCreatedAsync(); + + try + { + var e = new ParamItem { Price = value }; + ctx.Add(e); + await ctx.SaveChangesAsync(); + + var got = await ctx.Items.SingleAsync(x => x.Id == e.Id); + + Assert.Equal(value, got.Price); + + var tms = ctx.GetService(); + var et = ctx.Model.FindEntityType(typeof(ParamItem))!; + var prop = et.FindProperty(nameof(ParamItem.Price))!; + var mapping = tms.FindMapping(prop)!; + Assert.Equal($"Decimal({p}, {s})", mapping.StoreType); + } + finally + { + await DropItemsTableAsync(ctx, p, s); + } + } + + [Theory] + [MemberData(nameof(OverflowCases))] + public async Task Decimal_overflow_bubbles_up(int p, int s, decimal value) + { + await using var ctx = NewCtx(p, s); + await ctx.Database.EnsureCreatedAsync(); + + try + { + ctx.Add(new ParamItem { Price = value }); + await Assert.ThrowsAsync(() => ctx.SaveChangesAsync()); + } + finally + { + await DropItemsTableAsync(ctx, p, s); + } + } +} diff --git a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/ParametricDecimalContext.cs b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/ParametricDecimalContext.cs new file mode 100644 index 00000000..4f4f26be --- /dev/null +++ b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/ParametricDecimalContext.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace EntityFrameworkCore.Ydb.FunctionalTests.Query; + +public sealed class ParametricDecimalContext : DbContext +{ + private readonly int _p; + private readonly int _s; + + public ParametricDecimalContext(DbContextOptions options, int p, int s) + : base(options) + { + _p = p; + _s = s; + } + + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity(b => + { + b.ToTable($"Items_{_p}_{_s}"); + b.HasKey(x => x.Id); + b.Property(x => x.Price).HasPrecision(_p, _s); + }); + + internal sealed class CacheKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) + { + var ctx = (ParametricDecimalContext)context; + return (context.GetType(), designTime, ctx._p, ctx._s); + } + } +} + +public sealed class ParamItem +{ + public int Id { get; set; } + public decimal Price { get; set; } +}