From e97d5430c14b91f6ebc5e8fdd87dd275f9e0e20e Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 29 Jul 2026 10:50:28 +0800 Subject: [PATCH 1/6] Preserve constructor-seeded extra properties in Mapperly single-parameter Map --- .../MapperlyAutoObjectMappingProvider.cs | 45 +++++++++++- ...AutoMapperExtensibleDtoExtensions_Tests.cs | 6 +- .../Abp/Mapperly/AbpReverseMapperly_Tests.cs | 3 +- .../MapExtraPropertiesDefaultSeed_Tests.cs | 73 +++++++++++++++++++ .../Volo/Abp/Mapperly/MapperlyTestModule.cs | 15 +++- .../Mapperly/SampleClasses/MapperlyMappers.cs | 58 +++++++++++++++ 6 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs diff --git a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperlyAutoObjectMappingProvider.cs b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperlyAutoObjectMappingProvider.cs index 32dfa93807..6febfe0511 100644 --- a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperlyAutoObjectMappingProvider.cs +++ b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperlyAutoObjectMappingProvider.cs @@ -7,6 +7,7 @@ using System.Linq.Expressions; using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Data; +using Volo.Abp.DynamicProxy; using Volo.Abp.ObjectExtending; using Volo.Abp.ObjectMapping; @@ -44,9 +45,13 @@ public class MapperlyAutoObjectMappingProvider : IAutoObjectMappingProvider var mapper = ServiceProvider.GetService>(); if (mapper != null) { + var mapExtraPropertiesAttribute = mapper.GetType().GetSingleAttributeOrNull(); mapper.BeforeMap((TSource)source); var destination = mapper.Map((TSource)source); - TryMapExtraProperties(mapper.GetType().GetSingleAttributeOrNull(), (TSource)source, destination, new ExtraPropertyDictionary()); + var destinationExtraProperties = mapExtraPropertiesAttribute == null + ? new ExtraPropertyDictionary() + : GetDestinationExtraPropertiesSeed((TSource)source, destination); + TryMapExtraProperties(mapExtraPropertiesAttribute, (TSource)source, destination, destinationExtraProperties); mapper.AfterMap((TSource)source, destination); return destination; } @@ -54,9 +59,13 @@ public class MapperlyAutoObjectMappingProvider : IAutoObjectMappingProvider var reverseMapper = ServiceProvider.GetService>(); if (reverseMapper != null) { + var mapExtraPropertiesAttribute = reverseMapper.GetType().GetSingleAttributeOrNull(); reverseMapper.BeforeReverseMap((TSource)source); var destination = reverseMapper.ReverseMap((TSource)source); - TryMapExtraProperties(reverseMapper.GetType().GetSingleAttributeOrNull(), (TSource)source, destination, new ExtraPropertyDictionary()); + var destinationExtraProperties = mapExtraPropertiesAttribute == null + ? new ExtraPropertyDictionary() + : GetDestinationExtraPropertiesSeed((TSource)source, destination); + TryMapExtraProperties(mapExtraPropertiesAttribute, (TSource)source, destination, destinationExtraProperties); reverseMapper.AfterReverseMap((TSource)source, destination); return destination; } @@ -219,6 +228,38 @@ public class MapperlyAutoObjectMappingProvider : IAutoObjectMappingProvider return Expression.Lambda>(callConvert, instanceParam, sourceParam, destinationParam).Compile(); } + protected virtual ExtraPropertyDictionary GetDestinationExtraPropertiesSeed(TSource source, TDestination destination) + { + var extraProperties = new ExtraPropertyDictionary(); + if (source is not IHasExtraProperties sourceHasExtraProperties || + destination is not IHasExtraProperties destinationHasExtraProperties || + destinationHasExtraProperties.ExtraProperties is null) + { + return extraProperties; + } + + //Keys that don't exist in the source can only be set by the destination's constructor + foreach (var property in destinationHasExtraProperties.ExtraProperties) + { + if (sourceHasExtraProperties.ExtraProperties == null || !sourceHasExtraProperties.ExtraProperties.ContainsKey(property.Key)) + { + extraProperties[property.Key] = property.Value; + } + } + + //Source keys may be copied by the generated mapper, reset registered ones to their default value and let the filter map the source value + var destinationType = ProxyHelper.UnProxy(destinationHasExtraProperties).GetType(); + foreach (var property in ObjectExtensionManager.Instance.GetProperties(destinationType)) + { + if (!extraProperties.ContainsKey(property.Name) && destinationHasExtraProperties.ExtraProperties.ContainsKey(property.Name)) + { + extraProperties[property.Name] = property.GetDefaultValue(); + } + } + + return extraProperties; + } + protected virtual ExtraPropertyDictionary GetExtraProperties(TDestination destination) { var extraProperties = new ExtraPropertyDictionary(); diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Mapperly/AbpAutoMapperExtensibleDtoExtensions_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Mapperly/AbpAutoMapperExtensibleDtoExtensions_Tests.cs index 9eca171e4e..7e6d4321a4 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Mapperly/AbpAutoMapperExtensibleDtoExtensions_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Mapperly/AbpAutoMapperExtensibleDtoExtensions_Tests.cs @@ -53,8 +53,10 @@ public class AbpAutoMapperExtensibleDtoExtensions_Tests : AbpIntegratedTest(person); personDto.GetProperty("Name").ShouldBe("John"); //Defined in both classes - personDto.GetProperty("ChildCount").ShouldBe(0); //Not defined in the source, but was set to the default value by ExtensibleTestPersonDto constructor - personDto.GetProperty("CityName").ShouldBeNull(); //Ignored, but was set to the default value by ExtensibleTestPersonDto constructor + personDto.HasProperty("ChildCount").ShouldBeTrue(); //Not defined in the source, but was set to the default value by ExtensibleTestPersonDto constructor + personDto.GetProperty("ChildCount").ShouldBe(0); + personDto.HasProperty("CityName").ShouldBeTrue(); //Ignored, but was set to the default value by ExtensibleTestPersonDto constructor + personDto.GetProperty("CityName").ShouldBeNull(); personDto.HasProperty("Age").ShouldBeFalse(); //Not defined on the destination personDto.HasProperty("Sex").ShouldBeFalse(); //Not defined in both classes } diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs index 5d166f7dc8..72150477a0 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs @@ -97,6 +97,7 @@ public class AbpReverseMapperly_Tests : AbpIntegratedTest var entity = _objectMapper.Map(dto); entity.GetProperty("Tag").ShouldBe("ok"); - entity.HasProperty("Secret").ShouldBeFalse(); + entity.HasProperty("Secret").ShouldBeTrue(); //Ignored, but was set to the default value by the ExtensibleObject constructor + entity.GetProperty("Secret").ShouldBeNull(); } } diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs new file mode 100644 index 0000000000..72af0dcebc --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Data; +using Volo.Abp.ObjectMapping; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Mapperly; + +public class MapExtraPropertiesDefaultSeed_Tests : AbpIntegratedTest +{ + private readonly IObjectMapper _objectMapper; + + public MapExtraPropertiesDefaultSeed_Tests() + { + _objectMapper = ServiceProvider.GetRequiredService(); + } + + [Fact] + public void Single_Parameter_Map_Should_Preserve_Constructor_Seeded_Properties() + { + var entity = new ExtensibleSeededEntity { Id = Guid.NewGuid() } + .SetProperty("Tag", "ok"); + + var dto = _objectMapper.Map(entity); + + dto.GetProperty("Tag").ShouldBe("ok"); //Defined in both classes + dto.GetProperty("CreatedBy").ShouldBe("system"); //Set by the ExtensibleSeededDto constructor + dto.HasProperty("DtoOnly").ShouldBeTrue(); //Not defined in the source, but was set to the default value by the ExtensibleObject constructor + dto.GetProperty("DtoOnly").ShouldBeNull(); + } + + [Fact] + public void Single_Parameter_Map_Should_Not_Seed_Defaults_When_Destination_Disables_Them() + { + var entity = new ExtensibleSeededEntity { Id = Guid.NewGuid() } + .SetProperty("Tag", "ok"); + + var dto = _objectMapper.Map(entity); + + dto.GetProperty("Tag").ShouldBe("ok"); //Defined in both classes + dto.HasProperty("DtoOnly").ShouldBeFalse(); //ExtensibleNonSeededDto constructor disables the default values seeding + } + + [Fact] + public void Single_Parameter_Map_Should_Not_Leak_Filtered_Source_Values_Into_Registered_Keys() + { + var entity = new ExtensibleSeededEntity { Id = Guid.NewGuid() } + .SetProperty("Tag", "ok") + .SetProperty("DtoOnly", "leaked"); + + var dto = _objectMapper.Map(entity); + + dto.GetProperty("Tag").ShouldBe("ok"); //Defined in both classes + dto.HasProperty("DtoOnly").ShouldBeTrue(); //Reset to the registered default value + dto.GetProperty("DtoOnly").ShouldBeNull(); //The source value must not leak + } + + [Fact] + public void Single_Parameter_Map_Should_Not_Invoke_Default_Value_Factories_Without_MapExtraProperties_Attribute() + { + var entity = new ExtensibleSeededEntity { Id = Guid.NewGuid() } + .SetProperty("Tag", "ok"); + + var callsBefore = ExtensibleNoAttributeDto.CountedDefaultValueFactoryCalls; + + var dto = _objectMapper.Map(entity); + + (ExtensibleNoAttributeDto.CountedDefaultValueFactoryCalls - callsBefore).ShouldBe(1); //Only the ExtensibleNoAttributeDto constructor seeding + dto.HasProperty("Counted").ShouldBeTrue(); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapperlyTestModule.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapperlyTestModule.cs index 5153109472..eafce45f89 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapperlyTestModule.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapperlyTestModule.cs @@ -21,7 +21,20 @@ public class MapperlyTestModule : AbpModule .AddOrUpdateProperty("Tag") .AddOrUpdateProperty("Secret") .AddOrUpdateProperty("Tag") - .AddOrUpdateProperty("Secret"); + .AddOrUpdateProperty("Secret") + .AddOrUpdateProperty("Tag") + .AddOrUpdateProperty("Tag") + .AddOrUpdateProperty("DtoOnly") + .AddOrUpdateProperty("Tag") + .AddOrUpdateProperty("DtoOnly") + .AddOrUpdateProperty("Counted", options => + { + options.DefaultValueFactory = () => + { + ExtensibleNoAttributeDto.CountedDefaultValueFactoryCalls++; + return null!; + }; + }); }); } } diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MapperlyMappers.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MapperlyMappers.cs index 218ed36e1c..e45ce86d57 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MapperlyMappers.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MapperlyMappers.cs @@ -105,4 +105,62 @@ public partial class ExtensibleReverseMapper : TwoWayMapperBase +{ + public override partial ExtensibleSeededDto Map(ExtensibleSeededEntity source); + + public override partial void Map(ExtensibleSeededEntity source, ExtensibleSeededDto destination); +} + +[Mapper] +[MapExtraProperties] +public partial class ExtensibleNonSeededMapper : MapperBase +{ + public override partial ExtensibleNonSeededDto Map(ExtensibleSeededEntity source); + + public override partial void Map(ExtensibleSeededEntity source, ExtensibleNonSeededDto destination); +} + +[Mapper] +public partial class ExtensibleNoAttributeMapper : MapperBase +{ + public override partial ExtensibleNoAttributeDto Map(ExtensibleSeededEntity source); + + public override partial void Map(ExtensibleSeededEntity source, ExtensibleNoAttributeDto destination); } \ No newline at end of file From 58f97db029effb539914aa63d28a994456d3bdc3 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 29 Jul 2026 17:56:47 +0800 Subject: [PATCH 2/6] Add source immutability test for Mapperly single-parameter Map --- .../MapExtraPropertiesDefaultSeed_Tests.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs index 72af0dcebc..bc3ee341ae 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/MapExtraPropertiesDefaultSeed_Tests.cs @@ -31,6 +31,21 @@ public class MapExtraPropertiesDefaultSeed_Tests : AbpIntegratedTest(entity); + + ReferenceEquals(entity.ExtraProperties, originalReference).ShouldBeTrue(); + entity.GetProperty("Tag").ShouldBe("ok"); + entity.GetProperty("CreatedBy").ShouldBe("leaked"); + } + [Fact] public void Single_Parameter_Map_Should_Not_Seed_Defaults_When_Destination_Disables_Them() { @@ -53,8 +68,7 @@ public class MapExtraPropertiesDefaultSeed_Tests : AbpIntegratedTest(entity); dto.GetProperty("Tag").ShouldBe("ok"); //Defined in both classes - dto.HasProperty("DtoOnly").ShouldBeTrue(); //Reset to the registered default value - dto.GetProperty("DtoOnly").ShouldBeNull(); //The source value must not leak + dto.GetProperty("DtoOnly").ShouldBeNull(); //Not defined in the source, the source value must not leak } [Fact] From 17cab860f8086b350569c4cbee09881583b3ff8b Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 29 Jul 2026 20:37:41 +0800 Subject: [PATCH 3/6] Upgrade MySql.EntityFrameworkCore to 10.0.9 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ - + From 260594ff468436f69fcba62e33263693170c5e3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 12:38:49 +0000 Subject: [PATCH 4/6] docs: update package version changes [skip ci] --- docs/en/package-version-changes.md | 1 + 1 file changed, 1 insertion(+) 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 | From 11b78a9e7c830cad6b7a3b01bcbabfc20619787e Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 30 Jul 2026 10:05:54 +0800 Subject: [PATCH 5/6] Add MySQL type mapping plugin for Guid[] query parameters --- ...textConfigurationContextMySQLExtensions.cs | 20 ++++--- .../AbpMySQLDbContextOptionsExtension.cs | 48 +++++++++++++++ .../MySQLGuidArrayTypeMappingSourcePlugin.cs | 25 ++++++++ .../Volo.Abp.EntityFrameworkCore.Tests.csproj | 1 + ...LGuidArrayTypeMappingSourcePlugin_Tests.cs | 58 +++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/AbpMySQLDbContextOptionsExtension.cs create mode 100644 framework/src/Volo.Abp.EntityFrameworkCore.MySQL/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/MySQL/MySQLGuidArrayTypeMappingSourcePlugin_Tests.cs 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; } + } +} From 7d3ba2ec40d5608af328df372655cd478d2c9a96 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 30 Jul 2026 10:05:54 +0800 Subject: [PATCH 6/6] Map IdentityUserPasskey.Data as a json column for MySQL providers --- ...IdentityDbContextModelBuilderExtensions.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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(); });