diff --git a/Directory.Packages.props b/Directory.Packages.props index d0eb7e479f..f809304e31 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -142,7 +142,7 @@ - + diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md index aa1d4e4ed2..98ee15b919 100644 --- a/docs/en/package-version-changes.md +++ b/docs/en/package-version-changes.md @@ -11,6 +11,7 @@ | Package | Old Version | New Version | PR | |---------|-------------|-------------|-----| +| MySql.EntityFrameworkCore | 10.0.1 | 10.0.9 | #25896 | | Scriban | 7.2.1 | 7.2.5 | #25862 | | Swashbuckle.AspNetCore | 10.0.1 | 10.2.3 | #25759 | | System.Security.Cryptography.Xml | 10.0.7 | 10.0.10 | #25862 | diff --git a/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/AbpDbContextConfigurationContextMySQLExtensions.cs b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/AbpDbContextConfigurationContextMySQLExtensions.cs index f04f8fd4f3..2ec3383ddc 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/AbpDbContextConfigurationContextMySQLExtensions.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/AbpDbContextConfigurationContextMySQLExtensions.cs @@ -1,7 +1,9 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using System; using Volo.Abp.EntityFrameworkCore.DependencyInjection; +using Volo.Abp.EntityFrameworkCore.MySQL; namespace Volo.Abp.EntityFrameworkCore; @@ -11,21 +13,21 @@ public static class AbpDbContextConfigurationContextMySQLExtensions [NotNull] this AbpDbContextConfigurationContext context, Action? mySQLOptionsAction = null) { - if (context.ExistingConnection != null) - { - return context.DbContextOptions.UseMySQL(context.ExistingConnection, optionsBuilder => + var dbContextOptionsBuilder = context.ExistingConnection != null + ? context.DbContextOptions.UseMySQL(context.ExistingConnection, optionsBuilder => { optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); mySQLOptionsAction?.Invoke(optionsBuilder); - }); - } - else - { - return context.DbContextOptions.UseMySQL(context.ConnectionString, optionsBuilder => + }) + : context.DbContextOptions.UseMySQL(context.ConnectionString, optionsBuilder => { optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); mySQLOptionsAction?.Invoke(optionsBuilder); }); - } + + ((IDbContextOptionsBuilderInfrastructure)dbContextOptionsBuilder) + .AddOrUpdateExtension(new AbpMySQLDbContextOptionsExtension()); + + return dbContextOptionsBuilder; } } diff --git a/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/AbpMySQLDbContextOptionsExtension.cs b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/AbpMySQLDbContextOptionsExtension.cs new file mode 100644 index 0000000000..bea4ed8432 --- /dev/null +++ b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/AbpMySQLDbContextOptionsExtension.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Volo.Abp.EntityFrameworkCore.MySQL; + +/* Registers ABP services into the EF Core internal service provider to patch + * MySQL provider issues (currently the Guid[] type mapping plugin). */ +internal sealed class AbpMySQLDbContextOptionsExtension : IDbContextOptionsExtension +{ + public DbContextOptionsExtensionInfo Info => new ExtensionInfo(this); + + public void ApplyServices(IServiceCollection services) + { + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + } + + public void Validate(IDbContextOptions options) + { + } + + private sealed class ExtensionInfo : DbContextOptionsExtensionInfo + { + public ExtensionInfo(IDbContextOptionsExtension extension) + : base(extension) + { + } + + public override bool IsDatabaseProvider => false; + + public override string LogFragment => "using AbpMySQL "; + + public override int GetServiceProviderHashCode() => 0; + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) + { + return other is ExtensionInfo; + } + + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["Volo.Abp.EntityFrameworkCore.MySQL"] = "1"; + } + } +} diff --git a/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin.cs b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin.cs new file mode 100644 index 0000000000..e3ac23563c --- /dev/null +++ b/framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin.cs @@ -0,0 +1,25 @@ +using System; +using System.Data; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Volo.Abp.EntityFrameworkCore.MySQL; + +/* MySql.EntityFrameworkCore (up to 10.0.9) maps Guid[] query parameters to its + * scalar GUID mapping and throws NullReferenceException at parameter binding. + * This plugin runs before the provider's own lookup and returns the collection + * mapping the provider already builds for List. Remove once the provider + * handles Guid[] parameters. */ +internal sealed class MySQLGuidArrayTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin +{ + public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) + { + if (mappingInfo.ClrType == typeof(Guid[]) && mappingInfo.ElementTypeMapping is not null) + { + return new StringTypeMapping("longtext", DbType.String).Clone( + clrType: typeof(Guid[]), + elementMapping: mappingInfo.ElementTypeMapping); + } + + return null; + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo.Abp.EntityFrameworkCore.Tests.csproj b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo.Abp.EntityFrameworkCore.Tests.csproj index 77b1448ded..137dba20f4 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo.Abp.EntityFrameworkCore.Tests.csproj +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo.Abp.EntityFrameworkCore.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin_Tests.cs new file mode 100644 index 0000000000..2114e87b0e --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin_Tests.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.EntityFrameworkCore.DependencyInjection; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.MySQL; + +public class MySQLGuidArrayTypeMappingSourcePlugin_Tests +{ + /* Runs without a MySQL server: type mapping lookup is metadata-only, no + * connection is opened. When upgrading the provider, verify its native + * Guid[] mapping without the plugin registered; once the provider handles + * Guid[] itself, remove MySQLGuidArrayTypeMappingSourcePlugin together + * with this test. */ + [Fact] + public void UseMySQL_Should_Map_Guid_Array_Parameter_To_A_Collection_Mapping() + { + var services = new ServiceCollection(); + services.AddLogging(); + + var configurationContext = new AbpDbContextConfigurationContext( + "Server=localhost;Database=_;Uid=_;Pwd=_;", + services.BuildServiceProvider(), + null, + null); + configurationContext.UseMySQL(); + + using var dbContext = new PluginTestDbContext(configurationContext.DbContextOptions.Options); + var typeMappingSource = dbContext.GetService(); + var elementMapping = typeMappingSource.FindMapping(typeof(Guid))!; + + var mapping = typeMappingSource.FindMapping(typeof(Guid[]), dbContext.Model, elementMapping); + + mapping.ShouldNotBeNull(); + mapping.ClrType.ShouldBe(typeof(Guid[])); + mapping.StoreType.ShouldBe("longtext"); + mapping.ElementTypeMapping.ShouldBe(elementMapping); + } + + private class PluginTestDbContext : DbContext + { + public PluginTestDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Entities => Set(); + } + + private class PluginTestEntity + { + public Guid Id { get; set; } + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs index 429863c5ae..c1a94abce6 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs @@ -1,6 +1,8 @@ using System; +using System.Text.Json; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Volo.Abp.EntityFrameworkCore.Modeling; using Volo.Abp.Users.EntityFrameworkCore; @@ -187,7 +189,28 @@ public static class IdentityDbContextModelBuilderExtensions b.HasKey(p => p.CredentialId); b.Property(p => p.CredentialId).HasMaxLength(IdentityUserPasskeyConsts.MaxCredentialIdLength); // Defined in WebAuthn spec to be no longer than 1023 bytes - b.OwnsOne(p => p.Data).ToJson(); + + if (builder.IsUsingMySQL()) + { + /* MySQL providers do not support EF Core JSON columns (ToJson), + * so store Data as a serialized json column with the same column + * name and content. The comparer detects in-place mutations + * (e.g. sign count updates on login). */ + b.Property(p => p.Data) + .HasColumnName(nameof(IdentityUserPasskey.Data)) + .HasColumnType("json") + .HasConversion( + d => JsonSerializer.Serialize(d, (JsonSerializerOptions?)null), + s => JsonSerializer.Deserialize(s, (JsonSerializerOptions?)null)!, + new ValueComparer( + (l, r) => JsonSerializer.Serialize(l, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(r, (JsonSerializerOptions?)null), + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), + v => JsonSerializer.Deserialize(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); + } + else + { + b.OwnsOne(p => p.Data).ToJson(); + } b.ApplyObjectExtensionMappings(); });