From 8d61e4324cc5c39f5768ca8265e4fe5d13c53c2e Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Mon, 25 May 2026 14:01:50 +0800 Subject: [PATCH 01/32] test: ensure Compile skips open generic mapping rules (#925) Add regression coverage for configuring ClassA<> to ClassB<> and calling Compile() without throwing, matching the documented validation behavior. Co-authored-by: Cursor --- .../WhenMappingWithOpenGenerics.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs b/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs index 7abb5e1f..118f1c75 100644 --- a/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs +++ b/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs @@ -33,6 +33,23 @@ public void Setting_From_OpenGeneric_Has_No_SideEffect() var cCopy = c.Adapt(config); } + /// + /// https://github.com/MapsterMapper/Mapster/issues/925 + /// + [TestMethod] + public void Compile_With_Open_Generic_Mapping_Does_Not_Throw() + { + var config = new TypeAdapterConfig(); + config.ForType(typeof(ClassA<>), typeof(ClassB<>)); + + Should.NotThrow(() => config.Compile()); + + var classA = new ClassA { Variable = 15 }; + var classB = classA.Adapt>(config); + + classB.Variable.ShouldBe(15); + } + [TestMethod] public void MapOpenGenericsUseInherits() { @@ -102,5 +119,15 @@ class A { public string AProperty { get; set; } } class B { public string BProperty { get; set; } } class C { public string BProperty { get; set; } } + + class ClassA + { + public T? Variable { get; set; } + } + + class ClassB + { + public T? Variable { get; set; } + } } } From a0aee31392aa010116334d5988334fca21582170 Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Mon, 25 May 2026 14:37:05 +0800 Subject: [PATCH 02/32] fix: guard nullable navigation in ProjectToType record ctor mapping (#898) When a nullable navigation maps to a record DTO constructor parameter, skip nested mapping when the source is null instead of evaluating record ctor args against a null navigation. Also track NullChecks by expression identity so nested projection parameters are not skipped solely because they share a nullable type with a parent getter. Co-authored-by: Cursor --- .../WhenAddCtorNullablePropagation.cs | 25 +++++++++++++------ src/Mapster/Adapters/BaseClassAdapter.cs | 9 ++++--- src/Mapster/Utils/ExpressionEx.cs | 10 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/Mapster.Tests/WhenAddCtorNullablePropagation.cs b/src/Mapster.Tests/WhenAddCtorNullablePropagation.cs index b13eefa6..d321f971 100644 --- a/src/Mapster.Tests/WhenAddCtorNullablePropagation.cs +++ b/src/Mapster.Tests/WhenAddCtorNullablePropagation.cs @@ -1,5 +1,5 @@ -using Mapster.Tests.Classes; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; using System.Collections.Generic; using System.Linq; @@ -15,14 +15,25 @@ public class WhenAddCtorNullablePropagation [TestMethod] public void NullablePropagationFromCtorWorking() { - var source = new List(); + var source = new List + { + new() { Id = 1, Cod = new OrderCodEntity898 { Value = 42L } }, + new() { Id = 2, Cod = null }, + }; - source.Add(new OrderEntity898() { Id = 1, Cod = new OrderCodEntity898 { Value = 42L } }); - source.Add(new OrderEntity898() { Id = 2, Cod = null }); - - var str = new OrderEntity898() { Id = 1, Cod = new OrderCodEntity898 { Value = 42L } }.BuildAdapter().CreateProjectionExpression(); + Should.NotThrow(() => + { + source.AsQueryable().BuildAdapter().CreateProjectionExpression(); + }); var result = source.AsQueryable().ProjectToType().ToList(); + + result.Count.ShouldBe(2); + result[0].Id.ShouldBe(1); + result[0].Cod.ShouldNotBeNull(); + result[0].Cod!.Value.ShouldBe(42L); + result[1].Id.ShouldBe(2); + result[1].Cod.ShouldBeNull(); } } diff --git a/src/Mapster/Adapters/BaseClassAdapter.cs b/src/Mapster/Adapters/BaseClassAdapter.cs index 6d12a934..140f8b18 100644 --- a/src/Mapster/Adapters/BaseClassAdapter.cs +++ b/src/Mapster/Adapters/BaseClassAdapter.cs @@ -218,9 +218,9 @@ protected Expression CreateInstantiationExpression(Expression source, ClassMappi var members = classConverter.Members; var arguments = new List(); + arg.Context.NullChecks.UnionWith(members.Where(x => x.Getter != null).Select(x => (x.Getter, arg))); foreach (var member in members) { - arg.Context.NullChecks.UnionWith(members.Where(x=>x.Getter != null).Select(x=>(x.Getter,arg))); var parameterInfo = (ParameterInfo)member.DestinationMember.Info!; Expression defaultConst; Expression getter; @@ -253,12 +253,13 @@ protected Expression CreateInstantiationExpression(Expression source, ClassMappi else { - if (member.Getter.CanBeNull() && member.DestinationMember.Type.IsAbstractOrNotPublicCtor() - && member.Ignore.Condition == null) + if (member.Getter.CanBeNull() && member.Ignore.Condition == null + && (member.DestinationMember.Type.IsAbstractOrNotPublicCtor() + || member.DestinationMember.Type.UnwrapNullable().IsRecordType())) { var compareNull = Expression.Equal(member.Getter, Expression.Constant(null, member.Getter.Type)); getter = Expression.Condition(ExpressionEx.Not(compareNull), - CreateAdaptExpression(member.Getter, member.DestinationMember.Type, arg, member), + CreateAdaptExpressionCore(member.Getter, member.DestinationMember.Type, arg, member), defaultConst); } else diff --git a/src/Mapster/Utils/ExpressionEx.cs b/src/Mapster/Utils/ExpressionEx.cs index b7ffc365..bffdefde 100644 --- a/src/Mapster/Utils/ExpressionEx.cs +++ b/src/Mapster/Utils/ExpressionEx.cs @@ -453,8 +453,8 @@ public static Expression ApplyNullPropagationFromCtor(this Expression getter, Ex Expression? condition = null; var current = getter; var checks = arg.Context.NullChecks - .Where(x=> !object.ReferenceEquals(x.arg,arg)) - .Select(x=>x.param?.Type); + .Where(x => !object.ReferenceEquals(x.arg, arg)) + .Select(x => x.param); while (current != null) { @@ -462,9 +462,9 @@ public static Expression ApplyNullPropagationFromCtor(this Expression getter, Ex if (current.CanBeNull() && current is not ParameterExpression) compareNull = Expression.NotEqual(current, Expression.Constant(null, current.Type)); - else if (current.CanBeNull() && current is ParameterExpression - && !checks.Contains(current.Type)) - compareNull = Expression.NotEqual(current, Expression.Constant(null, current.Type)); + else if (current.CanBeNull() && current is ParameterExpression param + && !checks.Contains(param)) + compareNull = Expression.NotEqual(param, Expression.Constant(null, param.Type)); if (compareNull != null) { From 8b42d4a150f09d3c5beb406092d030ed080348ba Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Mon, 25 May 2026 16:07:27 +0800 Subject: [PATCH 03/32] fix: cast inherited MapWith results to derived destinations (#947) When AllowImplicitDestinationInheritance merges a base MapWith converter into a derived destination mapping, downcast the converter result so Adapt() no longer throws InvalidCastException. Co-authored-by: Cursor --- ...citInheritanceMapWithDerivedDestination.cs | 75 +++++++++++++++++++ src/Mapster/TypeAdapterConfig.cs | 18 ++++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs diff --git a/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs b/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs new file mode 100644 index 00000000..0bb500c9 --- /dev/null +++ b/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs @@ -0,0 +1,75 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; + +namespace Mapster.Tests +{ + /// + /// https://github.com/MapsterMapper/Mapster/issues/947 + /// + [TestClass] + public class WhenImplicitInheritanceMapWithDerivedDestination + { + [TestCleanup] + public void Cleanup() + { + TypeAdapterConfig.GlobalSettings.Clear(); + TypeAdapterConfig.GlobalSettings.AllowImplicitDestinationInheritance = false; + } + + [TestMethod] + public void Inherited_MapWith_On_Base_Destination_Casts_To_Derived_Destination() + { + var config = new TypeAdapterConfig(); + config.AllowImplicitDestinationInheritance = true; + config.NewConfig() + .MapWith(src => src.Type == "Bird" + ? (Animal947)new Bird947 { AnimalValue = src.AnimalValueDto } + : new Dog947 { AnimalValue = src.AnimalValueDto }); + + var source = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Dog" }; + + var dog = source.Adapt(config); + + dog.ShouldBeOfType(); + dog.AnimalValue.ShouldBe("Hello"); + } + + [TestMethod] + public void Inherited_MapWith_Works_For_Explicit_Source_Destination_Pair() + { + var config = new TypeAdapterConfig(); + config.AllowImplicitDestinationInheritance = true; + config.NewConfig() + .MapWith(src => src.Type == "Bird" + ? (Animal947)new Bird947 { AnimalValue = src.AnimalValueDto } + : new Dog947 { AnimalValue = src.AnimalValueDto }); + + var source = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Dog" }; + + var dog = source.Adapt(config); + + dog.ShouldBeOfType(); + dog.AnimalValue.ShouldBe("Hello"); + } + + public abstract class Animal947 + { + public string AnimalValue { get; set; } = null!; + } + + public class Dog947 : Animal947 + { + } + + public class Bird947 : Animal947 + { + } + + public class AnimalDto947 + { + public string AnimalValueDto { get; set; } = null!; + + public string Type { get; set; } = null!; + } + } +} diff --git a/src/Mapster/TypeAdapterConfig.cs b/src/Mapster/TypeAdapterConfig.cs index 1dce2053..af88d5bd 100644 --- a/src/Mapster/TypeAdapterConfig.cs +++ b/src/Mapster/TypeAdapterConfig.cs @@ -431,7 +431,7 @@ private static LambdaExpression CreateMapExpression(CompileArgument arg) throw new CompileException(arg, new InvalidOperationException("ConverterFactory is not found")); try { - return fn(arg); + return AdjustInheritedConverterReturnType(fn(arg), arg); } catch (Exception ex) { @@ -439,6 +439,22 @@ private static LambdaExpression CreateMapExpression(CompileArgument arg) } } + private static LambdaExpression AdjustInheritedConverterReturnType(LambdaExpression lambda, CompileArgument arg) + { + var destinationType = arg.DestinationType; + var returnType = lambda.ReturnType; + if (returnType == destinationType) + return lambda; + + // MapWith configured on a base destination type returns the base type, but implicit + // destination inheritance can compile the converter for a derived destination. + if (!returnType.IsAssignableFrom(destinationType)) + return lambda; + + var body = lambda.Body.To(destinationType, force: true); + return Expression.Lambda(body, lambda.Parameters); + } + private LambdaExpression CreateDynamicMapExpression(TypeTuple tuple) { var lambda = CreateMapExpression(tuple, MapType.Map); From 98f1afa750d0393d8f11a9609200f8c5c3a72552 Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Mon, 25 May 2026 16:10:58 +0800 Subject: [PATCH 04/32] fix: support CompileProjection with Include on abstract types (#801) Generate TypeIs-based conditional projection expressions when mapping abstract destination hierarchies with Include, and harden inline class mapping when instantiation cannot produce a NewExpression. Also add regression coverage for not-self-creation types inside containing classes (#911). Co-authored-by: Cursor --- .../WhenIncludeDerivedClasses.cs | 31 +++++++++++++ .../WhenMappingRecordRegression.cs | 43 +++++++++++++++++++ src/Mapster/Adapters/ClassAdapter.cs | 40 ++++++++++++++++- 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/Mapster.Tests/WhenIncludeDerivedClasses.cs b/src/Mapster.Tests/WhenIncludeDerivedClasses.cs index 3002766a..3af19256 100644 --- a/src/Mapster.Tests/WhenIncludeDerivedClasses.cs +++ b/src/Mapster.Tests/WhenIncludeDerivedClasses.cs @@ -40,6 +40,37 @@ public void Map_Including_Derived_Class_With_List() ((BikeDto)dto[1]).Brand.ShouldBe("BMX"); } + /// + /// https://github.com/MapsterMapper/Mapster/issues/801 + /// + [TestMethod] + public void CompileProjection_Including_Derived_Class() + { + TypeAdapterConfig.NewConfig() + .Include() + .CompileProjection(); + } + + public abstract class PocoA801 + { + public int Id { get; set; } + } + + public class PocoDerived801 : PocoA801 + { + public int DerivedVal { get; set; } + } + + public abstract class DtoA801 + { + public int Id { get; set; } + } + + public class DtoDerived801 : DtoA801 + { + public int DerivedVal { get; set; } + } + #region test classes public abstract class Vehicle { diff --git a/src/Mapster.Tests/WhenMappingRecordRegression.cs b/src/Mapster.Tests/WhenMappingRecordRegression.cs index 13ff3ea3..bcf0c96e 100644 --- a/src/Mapster.Tests/WhenMappingRecordRegression.cs +++ b/src/Mapster.Tests/WhenMappingRecordRegression.cs @@ -539,6 +539,49 @@ public void NotSelfCreationTypeMappingToSelfWithOutError() resultJ.RootElement.GetProperty("key").ToString().ShouldBe("value"); } + /// + /// https://github.com/MapsterMapper/Mapster/issues/911 + /// + [TestMethod] + public void NotSelfCreationTypeMappingInContainingClassWithoutError() + { + var jsonSource = new SourceClassWithJsonDocument911 + { + Json = JsonDocument.Parse("{\"key\": \"value\"}") + }; + + var uriSource = new SourceClassWithUri911 + { + Uri = new Uri("https://www.google.com/") + }; + + var jsonDest = jsonSource.Adapt(); + var uriDest = uriSource.Adapt(); + + jsonDest.Json.RootElement.GetProperty("key").GetString().ShouldBe("value"); + uriDest.Uri.ToString().ShouldBe("https://www.google.com/"); + } + + class SourceClassWithJsonDocument911 + { + public required JsonDocument Json { get; init; } + } + + class DestinationClassWithJsonDocument911 + { + public required JsonDocument Json { get; init; } + } + + class SourceClassWithUri911 + { + public required Uri Uri { get; init; } + } + + class DestinationClassWithUri911 + { + public required Uri Uri { get; init; } + } + /// /// https://github.com/MapsterMapper/Mapster/issues/927 /// diff --git a/src/Mapster/Adapters/ClassAdapter.cs b/src/Mapster/Adapters/ClassAdapter.cs index 27d09d9b..d71067b0 100644 --- a/src/Mapster/Adapters/ClassAdapter.cs +++ b/src/Mapster/Adapters/ClassAdapter.cs @@ -219,9 +219,17 @@ private static Expression SetValueByReflection(MemberMapping member, MemberExpre // Prop2 = convert(src.Prop2), //} + if (arg.MapType == MapType.Projection && arg.DestinationType.IsAbstract && arg.Settings.Includes.Count > 0) + return CreateIncludeProjectionExpression(source, arg); + var exp = CreateInstantiationExpression(source, arg); + if (exp.NodeType == ExpressionType.Throw) + return null; + var memberInit = exp as MemberInitExpression; - var newInstance = memberInit?.NewExpression ?? (NewExpression)exp; + var newInstance = memberInit?.NewExpression ?? exp as NewExpression; + if (newInstance == null) + return null; var contructorMembers = newInstance.GetAllMemberExpressionsMemberInfo().ToArray(); ClassModel? classModel; ClassMapping? classConverter; @@ -272,6 +280,36 @@ private static Expression SetValueByReflection(MemberMapping member, MemberExpre return Expression.MemberInit(newInstance, lines); } + static Expression CreateIncludeProjectionExpression(Expression source, CompileArgument arg) + { + Expression body = Expression.Default(arg.DestinationType); + foreach (var tuple in arg.Settings.Includes) + { + var itemTuple = tuple; + if (tuple.Source.IsOpenGenericType() && tuple.Destination.IsOpenGenericType()) + { + var genericArg = source.Type.GetGenericArguments(); + itemTuple = new TypeTuple(tuple.Source.MakeGenericType(genericArg), tuple.Destination.MakeGenericType(genericArg)); + } + + if (itemTuple.Source == arg.SourceType) + continue; + + if (!arg.SourceType.GetTypeInfo().IsAssignableFrom(itemTuple.Source.GetTypeInfo())) + continue; + + if (!arg.DestinationType.GetTypeInfo().IsAssignableFrom(itemTuple.Destination.GetTypeInfo())) + continue; + + var test = Expression.TypeIs(source, itemTuple.Source); + var cast = Expression.TypeAs(source, itemTuple.Source); + var mapped = CreateAdaptExpressionCore(cast!, itemTuple.Destination, arg); + body = Expression.Condition(test, mapped.To(arg.DestinationType, true), body); + } + + return body; + } + protected override Expression CreateExpressionBody(Expression source, Expression? destination, CompileArgument arg) { TypeAdapterRule? rule; From 49d670e9683cb9b61f5f07321e71480c14eb90fd Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Mon, 25 May 2026 16:28:35 +0800 Subject: [PATCH 05/32] fix: fall back to src-relative snk when SolutionDir is unset (#848) Allow `dotnet test path/to/csproj` to resolve the shared assembly key from Directory.Build.props via MSBuildThisFileDirectory when SolutionDir is not provided by the build. --- src/Directory.Build.props | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4eff7b00..a3b70db2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,7 +20,9 @@ https://cloud.githubusercontent.com/assets/5763993/26522718/d16f3e42-4330-11e7-9b78-f8c7402624e7.png MIT true - $(SolutionDir)/Mapster/Mapster.snk + + $(SolutionDir)Mapster/Mapster.snk + $(MSBuildThisFileDirectory)Mapster/Mapster.snk true Mapper;AutoMapper;Fast;Mapping icon.png From 2c37aa1cfd9e8c3a4ea20e491d91f85f86d72ece Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 15 Jun 2026 13:24:35 +0500 Subject: [PATCH 06/32] fix(test): Drop OpenGeneric compile check because a separate test was added --- src/Mapster.Tests/WhenMappingWithOpenGenerics.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs b/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs index 118f1c75..19891357 100644 --- a/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs +++ b/src/Mapster.Tests/WhenMappingWithOpenGenerics.cs @@ -24,9 +24,7 @@ public void Setting_From_OpenGeneric_Has_No_SideEffect() config .NewConfig(typeof(A<>), typeof(B<>)) .Map("BProperty", "AProperty"); - - config.Compile(); // is not throw exception - + var a = new A { AProperty = "A" }; var c = new C { BProperty = "C" }; var b = a.Adapt>(config); // successful mapping From a2a25ead4f8e5b2fe01cce3d4f78630c4d6300f3 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Wed, 17 Jun 2026 12:28:27 +0500 Subject: [PATCH 07/32] feat: inherit MapWith and fix Mapping ValueType to NullableValue type using only ValueType to ValueType custom settings --- ...citInheritanceMapWithDerivedDestination.cs | 115 +++++++++++++++++- src/Mapster/TypeAdapterConfig.cs | 61 +++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs b/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs index 0bb500c9..d459e1eb 100644 --- a/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs +++ b/src/Mapster.Tests/WhenImplicitInheritanceMapWithDerivedDestination.cs @@ -1,5 +1,8 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.VisualStudio.TestTools.UnitTesting; using Shouldly; +using System; +using static Mapster.Tests.DynamicTypeGeneratorTests; namespace Mapster.Tests { @@ -27,11 +30,16 @@ public void Inherited_MapWith_On_Base_Destination_Casts_To_Derived_Destination() : new Dog947 { AnimalValue = src.AnimalValueDto }); var source = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Dog" }; + var sourceInsaider = new AnimalDtoInsaider947() { Animal = source }; var dog = source.Adapt(config); - + var dogInsaider = sourceInsaider.Adapt(config); + dog.ShouldBeOfType(); dog.AnimalValue.ShouldBe("Hello"); + + dogInsaider.Animal.ShouldBeOfType(); + dogInsaider.Animal.AnimalValue.ShouldBe("Hello"); } [TestMethod] @@ -52,6 +60,77 @@ public void Inherited_MapWith_Works_For_Explicit_Source_Destination_Pair() dog.AnimalValue.ShouldBe("Hello"); } + + [TestMethod] + public void Inherited_MapWith_On_Base_Destination_ReturnDefault_When_In_Runtime_ResultType_IsNot_Achievable() + { + var config = new TypeAdapterConfig(); + config.AllowImplicitDestinationInheritance = true; + config.NewConfig() + .MapWith(src => src.Type == "Bird" + ? (Animal947)new Bird947 { AnimalValue = src.AnimalValueDto } + : new Dog947 { AnimalValue = src.AnimalValueDto }); + + var source = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Bird" }; + + var dog = source.Adapt(config); + + dog.ShouldBeNull(); + dog.ShouldBe(default); + } + + + [TestMethod] + public void Inherited_MapWith_On_Base_Destination_Casts_To_Derived_Destination_UsingInterface() + { + var config = new TypeAdapterConfig(); + config.AllowImplicitDestinationInheritance = true; + config.NewConfig() + .MapWith(src => src.Type == "Bird" + ? new ValueTypeBird947 { AnimalValue = src.AnimalValueDto } + : new ValueTypeDog947 { AnimalValue = src.AnimalValueDto }); + + var dog = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Dog" }.Adapt(config); + var defaultdata = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Bird" }.Adapt(config); + + dog.ShouldBeOfType(); + dog.AnimalValue.ShouldBe("Hello"); + + defaultdata.ShouldBeOfType(); + defaultdata.AnimalValue.ShouldBe(default); + } + + [TestMethod] + public void Inherited_MapWith_On_Base_Destination_Casts_To_Derived_Destination_NullableValueType() + { + var config = new TypeAdapterConfig(); + config.AllowImplicitDestinationInheritance = true; + config.NewConfig() + .MapWith(src => src.Type == "Bird" + ? new ValueTypeBird947 { AnimalValue = src.AnimalValueDto } + : new ValueTypeDog947 { AnimalValue = src.AnimalValueDto }); + + var validSrc = new AnimalDto947 { AnimalValueDto = "Hello", Type = "Dog" }; + var invalidSrc = new AnimalDto947 { AnimalValueDto = "Tweet", Type = "Bird" }; ; + + var dog = validSrc.Adapt(config); + var Nulldata = invalidSrc.Adapt(config); + var InsaiderNullableDog = new AnimalDtoInsaider947() { Animal = validSrc}.Adapt(config); + var NullInsaiderNullableDog = new AnimalDtoInsaider947() { Animal = invalidSrc }.Adapt(config); + + dog.ShouldNotBeNull(); + dog?.AnimalValue.ShouldBe("Hello"); + InsaiderNullableDog.Animal.ShouldNotBeNull(); + InsaiderNullableDog.Animal?.AnimalValue.ShouldBe("Hello"); + + + Nulldata.ShouldBeNull(); + NullInsaiderNullableDog.Animal.ShouldBeNull(); + } + + + #region TestClases + public abstract class Animal947 { public string AnimalValue { get; set; } = null!; @@ -65,11 +144,45 @@ public class Bird947 : Animal947 { } + public class AnimalDto947 { public string AnimalValueDto { get; set; } = null!; public string Type { get; set; } = null!; } + + public class AnimalDtoInsaider947 + { + public AnimalDto947 Animal { get; set; } + } + + public class DogInsaider947 + { + public Dog947 Animal { get; set; } + } + + public interface IAnimal + { + public string AnimalValue { get; set; } + } + + public struct ValueTypeBird947 : IAnimal + { + public string AnimalValue { get; set; } + } + + public struct ValueTypeDog947 : IAnimal + { + public string AnimalValue { get; set; } + + } + + public class DogValueTypeNullableInsaider947 + { + public ValueTypeDog947? Animal { get; set; } + } + + #endregion TestClases } } diff --git a/src/Mapster/TypeAdapterConfig.cs b/src/Mapster/TypeAdapterConfig.cs index af88d5bd..938aeab9 100644 --- a/src/Mapster/TypeAdapterConfig.cs +++ b/src/Mapster/TypeAdapterConfig.cs @@ -1,13 +1,14 @@ -using System; +using Mapster.Adapters; +using Mapster.Models; +using Mapster.Utils; +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Data.SqlTypes; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; -using Mapster.Adapters; -using Mapster.Models; -using Mapster.Utils; namespace Mapster { @@ -266,6 +267,10 @@ private static TypeAdapterRule CreateDestinationTypeRule(TypeTuple key) private static int? GetSubclassDistance(Type type1, Type type2, bool allowInheritance) { + //Support for using ValueType mapping configurations of types, for mapping cases on Nulllable ValueType values + if (type1.IsNullable() && !type1.ContainsGenericParameters) + type1 = type1.GetGenericArguments().FirstOrDefault(); + if (type1 == type2) return 50; @@ -443,15 +448,59 @@ private static LambdaExpression AdjustInheritedConverterReturnType(LambdaExpress { var destinationType = arg.DestinationType; var returnType = lambda.ReturnType; + var lamdaBody = lambda.Body; + + // Support for using ValueType mapping configurations of types, for mapping cases on Nulllable ValueType values + if (arg.DestinationType.IsNullable() && !returnType.IsNullable() && returnType.IsValueType) + { + lamdaBody = Expression.Convert(lambda.Body, typeof(Nullable<>).MakeGenericType(lambda.ReturnType)); + lambda = Expression.Lambda(lamdaBody, lambda.Parameters); + + returnType = lambda.ReturnType; + } + if (returnType == destinationType) return lambda; + //Support for using ValueType mapping configurations of types, for mapping cases on Nulllable ValueType values + if (destinationType.IsNullable() && lambda.ReturnType.IsInterface) + { + var realDestType = destinationType.GetGenericArguments().FirstOrDefault(); + + if (realDestType is null || !returnType.IsAssignableFrom(realDestType)) + return lambda; + } // MapWith configured on a base destination type returns the base type, but implicit // destination inheritance can compile the converter for a derived destination. - if (!returnType.IsAssignableFrom(destinationType)) + else if (!returnType.IsAssignableFrom(destinationType)) return lambda; - var body = lambda.Body.To(destinationType, force: true); + Expression body; + + if(destinationType.CanBeNull()) + body = Expression.TypeAs(lamdaBody, destinationType); + else + { + var tempDest = Expression.Variable(returnType, "tempDest"); + + var variables = new[] {tempDest}; + + var blockbody = new List() { Expression.Assign(tempDest, lamdaBody) }; + + var condition = Expression.TypeIs(tempDest, destinationType); + UnaryExpression ifTrue = Expression.Convert(tempDest, destinationType); + DefaultExpression ifFalse = Expression.Default(destinationType); + + ConditionalExpression conditionalExpr = Expression.Condition(condition, ifTrue, ifFalse); + blockbody.Add(conditionalExpr); + + BlockExpression body2 = Expression.Block(variables, blockbody); + + return Expression.Lambda(body2,lambda.Parameters); + + } + + return Expression.Lambda(body, lambda.Parameters); } From fb49423afe08c7f6946c26c140edaa0ce622a56b Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Thu, 18 Jun 2026 19:02:13 +0500 Subject: [PATCH 08/32] fix: add Nullable Adapter --- src/Mapster/Adapters/BaseAdapter.cs | 23 +++++----------- src/Mapster/Adapters/NullableAdapter.cs | 36 +++++++++++++++++++++++++ src/Mapster/TypeAdapterConfig.cs | 1 + src/Mapster/Utils/ExpressionEx.cs | 19 +++---------- src/Mapster/Utils/ReflectionUtils.cs | 8 ++++++ 5 files changed, 55 insertions(+), 32 deletions(-) create mode 100644 src/Mapster/Adapters/NullableAdapter.cs diff --git a/src/Mapster/Adapters/BaseAdapter.cs b/src/Mapster/Adapters/BaseAdapter.cs index b31a0dbe..0e9c7923 100644 --- a/src/Mapster/Adapters/BaseAdapter.cs +++ b/src/Mapster/Adapters/BaseAdapter.cs @@ -485,12 +485,7 @@ protected Expression CreateAdaptExpression(Expression source, Type destinationTy } internal Expression CreateAdaptExpression(Expression source, Type destinationType, CompileArgument arg, MemberMapping? mapping, Expression? destination = null) { - Expression _source; - - if (arg.MapType != MapType.Projection) - _source = source.NullableEnumExtractor(); // Extraction Nullable Enum - else - _source = source; + Expression _source = source; if (_source.Type == destinationType && arg.MapType == MapType.Projection) return _source; @@ -506,20 +501,14 @@ internal Expression CreateAdaptExpression(Expression source, Type destinationTyp if (_source.Type == destinationType && arg.Settings.ShallowCopyForSameType == true && notUsingDestinationValue && rule == null) exp = _source; - else if (source is ConditionalExpression cond && mapping != null) - { - // convert ApplyNullable Propagation for NotPrimitive Nullable types - if (mapping.Getter.Type.IsNotPrimitiveNullableType() && !mapping.DestinationMember.Type.IsNullable()) - { - var adapt = CreateAdaptExpressionCore(cond.IfTrue.GetNotPrimitiveNullableValue(), mapping.DestinationMember.Type, arg, mapping); - exp = Expression.Condition(cond.Test, adapt, mapping.DestinationMember.Type.CreateDefault()); - } - else - exp = CreateAdaptExpressionCore(_source, destinationType, arg, mapping, destination); - } else exp = CreateAdaptExpressionCore(_source, destinationType, arg, mapping, destination); + // NullablePropagation when for member using Custom converter MapWith + if (notUsingDestinationValue && arg.MapType != MapType.Projection + && mapping != null && mapping.Getter.CanBeNull()) + exp = mapping.Getter.NotNullReturn(exp); + //transform(adapt(_source)); if (notUsingDestinationValue) { diff --git a/src/Mapster/Adapters/NullableAdapter.cs b/src/Mapster/Adapters/NullableAdapter.cs new file mode 100644 index 00000000..bfc7d836 --- /dev/null +++ b/src/Mapster/Adapters/NullableAdapter.cs @@ -0,0 +1,36 @@ +using Mapster.Utils; +using System.Linq.Expressions; + +namespace Mapster.Adapters +{ + internal class NullableAdapter : BaseAdapter + { + + protected override int Score => 0; //must do first + + protected override bool CanMap(PreCompileArgument arg) + { + return arg.SourceType.IsNullable() || arg.DestinationType.IsNullable(); + } + protected override bool CanInline(Expression source, Expression? destination, CompileArgument arg) + { + return true; + } + + protected override Expression? CreateInlineExpression(Expression source, CompileArgument arg, bool IsRequiredOnly = false) + { + var _source = source.Type.IsNullable() + ? Expression.Convert(source, source.Type.GetGenericArguments()[0]) + : source; + + Expression adapt = CreateAdaptExpression(_source, arg.DestinationType.GetNotNullableTypeDefenition(),arg); + + return adapt.ToNullableExp(arg); + } + + protected override Expression CreateBlockExpression(Expression source, Expression destination, CompileArgument arg) + { + return Expression.Empty(); + } + } +} diff --git a/src/Mapster/TypeAdapterConfig.cs b/src/Mapster/TypeAdapterConfig.cs index 938aeab9..9463072b 100644 --- a/src/Mapster/TypeAdapterConfig.cs +++ b/src/Mapster/TypeAdapterConfig.cs @@ -33,6 +33,7 @@ private static List CreateRuleTemplate() new ObjectAdapter().CreateRule(), //-111 new StringAdapter().CreateRule(), //-110 new EnumAdapter().CreateRule(), //-109 + new NullableAdapter().CreateRule(), // 0 //fallback rules new TypeAdapterRule diff --git a/src/Mapster/Utils/ExpressionEx.cs b/src/Mapster/Utils/ExpressionEx.cs index b7ffc365..df0b3058 100644 --- a/src/Mapster/Utils/ExpressionEx.cs +++ b/src/Mapster/Utils/ExpressionEx.cs @@ -537,22 +537,11 @@ internal static Expression GetNameConverterExpression(Func conve return Expression.Constant(converter); } - public static bool IsNotPrimitiveNullableType(this Type type) + public static Expression ToNullableExp(this Expression adapt, CompileArgument arg) { - return Nullable.GetUnderlyingType(type) != null && !type.IsMapsterPrimitive(); - } - - public static Expression GetNotPrimitiveNullableValue(this Expression exp) - { - if (exp.Type.IsNotPrimitiveNullableType()) - { - var getValueOrDefaultMethod = exp.Type.GetMethod("GetValueOrDefault", Type.EmptyTypes); - var getValue = Expression.Call(exp, getValueOrDefaultMethod); - - return getValue; - } - - return exp; + if (arg.DestinationType.IsNullable()) + return Expression.Convert(adapt, arg.DestinationType); + return adapt; } } diff --git a/src/Mapster/Utils/ReflectionUtils.cs b/src/Mapster/Utils/ReflectionUtils.cs index 81c3e21e..36a792fc 100644 --- a/src/Mapster/Utils/ReflectionUtils.cs +++ b/src/Mapster/Utils/ReflectionUtils.cs @@ -479,5 +479,13 @@ public static bool IsNotCustomConverterFactory(this CompileArgument arg, TypeAda return true; } + + public static Type GetNotNullableTypeDefenition(this Type inputType) + { + if (inputType.IsNullable()) + return inputType.GetGenericArguments()[0]; + + return inputType; + } } } From 221a7175d1f46f3c97ee44fbd6abcf8adcddfd2e Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 22 Jun 2026 08:52:01 +0500 Subject: [PATCH 09/32] feat(test): add Issue #987 test case --- ...enPropertyNullablePropagationRegression.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/Mapster.Tests/WhenPropertyNullablePropagationRegression.cs b/src/Mapster.Tests/WhenPropertyNullablePropagationRegression.cs index 1e1e27d9..61b79dbb 100644 --- a/src/Mapster.Tests/WhenPropertyNullablePropagationRegression.cs +++ b/src/Mapster.Tests/WhenPropertyNullablePropagationRegression.cs @@ -134,9 +134,55 @@ public void MapToTargetWorkCorrect() } + /// + /// https://github.com/MapsterMapper/Mapster/issues/987 + /// + [TestMethod] + public void CustomMapUsingNullableValueTypesWorkCorrect() + { + var config = new TypeAdapterConfig(); + config.ForType() + .Map(dest => dest.Value, src => src.UseSecondaryValue + ? src.SecondaryValue1 + src.SecondaryValue2 + : src.PrimaryValue); + + + var source1 = new SourceClass987 + { + UseSecondaryValue = false, + PrimaryValue = 100 + }; + var source2 = new SourceClass987 + { + UseSecondaryValue = true, + SecondaryValue1 = 10, + SecondaryValue2 = 20 + }; + + var result1 = source1.Adapt(config); + var result2 = source2.Adapt(config); + + result1.Value.ShouldBe(100); + result2.Value.ShouldBe(30); + } + } #region TestClasses + +public class SourceClass987 +{ + public bool UseSecondaryValue { get; set; } + public int? PrimaryValue { get; set; } + public int? SecondaryValue1 { get; set; } + public int? SecondaryValue2 { get; set; } +} + +public class DestinationClass987 +{ + public int Value { get; set; } +} + public enum Currency858 { Eur, From be6ee62005f45ee3831d5376214a724ff59bee8b Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 22 Jun 2026 15:21:42 +0500 Subject: [PATCH 10/32] fix: drop null check --- src/Mapster/Adapters/BaseAdapter.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mapster/Adapters/BaseAdapter.cs b/src/Mapster/Adapters/BaseAdapter.cs index 0e9c7923..58e95e86 100644 --- a/src/Mapster/Adapters/BaseAdapter.cs +++ b/src/Mapster/Adapters/BaseAdapter.cs @@ -504,10 +504,10 @@ internal Expression CreateAdaptExpression(Expression source, Type destinationTyp else exp = CreateAdaptExpressionCore(_source, destinationType, arg, mapping, destination); - // NullablePropagation when for member using Custom converter MapWith - if (notUsingDestinationValue && arg.MapType != MapType.Projection - && mapping != null && mapping.Getter.CanBeNull()) - exp = mapping.Getter.NotNullReturn(exp); + //// NullablePropagation when for member using Custom converter MapWith + //if (notUsingDestinationValue && arg.MapType != MapType.Projection + // && mapping != null && mapping.Getter.CanBeNull()) + // exp = mapping.Getter.NotNullReturn(exp); //transform(adapt(_source)); if (notUsingDestinationValue) From 2c7d600f175c5877d1bff3ce7805c305bcceefe0 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 23 Jun 2026 13:37:53 +0500 Subject: [PATCH 11/32] fix: add dependency for Mapster.EFCore TFM Net 8.0 (#988) --- src/Mapster.EFCore/Mapster.EFCore.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Mapster.EFCore/Mapster.EFCore.csproj b/src/Mapster.EFCore/Mapster.EFCore.csproj index 7e7922d3..b73fd4bb 100644 --- a/src/Mapster.EFCore/Mapster.EFCore.csproj +++ b/src/Mapster.EFCore/Mapster.EFCore.csproj @@ -11,6 +11,8 @@ + From ea15411d230594292997fd1bcf1292234f3f587b Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 23 Jun 2026 13:50:57 +0500 Subject: [PATCH 12/32] chore: Bump version to v10.0.9 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4eff7b00..af102088 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.8 + 10.0.9 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0 From c670690c40a5f1371e9adcf0c21e767e2b196ac7 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Thu, 25 Jun 2026 12:03:03 +0500 Subject: [PATCH 13/32] fix: quick fix Custom converter supporting from nullable ValueType --- .../WhenMappingNullablePrimitives.cs | 40 +++++++++++++++++++ src/Mapster/Adapters/NullableAdapter.cs | 19 ++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Mapster.Tests/WhenMappingNullablePrimitives.cs b/src/Mapster.Tests/WhenMappingNullablePrimitives.cs index 8d0fec3f..47aa080d 100644 --- a/src/Mapster.Tests/WhenMappingNullablePrimitives.cs +++ b/src/Mapster.Tests/WhenMappingNullablePrimitives.cs @@ -158,8 +158,48 @@ public void MappingNullTuple() result.Application.ShouldBeNull(); } + [TestMethod] + public void CustomConverterWorkWithNullablePrimitiveTypes() + { + TypeAdapterConfig config = new TypeAdapterConfig(); + config.NewConfig().MapWith(src => Helper992.ParseToNullableBool(src)); + config.Compile(); + + var src = new Source992 { Prop1 = "yes" }; + var result = src.Adapt(config); + + result.Prop1.ShouldBe(true); + + } + + #region TestClasses + static class Helper992 + { + public static bool? ParseToNullableBool(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + return value.Trim().ToLower() switch + { + "true" or "t" or "yes" or "1" => true, + "false" or "f" or "no" or "0" => false, + _ => null + }; + } + } + + public class Source992 + { + public string? Prop1 { get; set; } + } + + public class Destination992 + { + public bool? Prop1 { get; set; } + } public class Output414 { diff --git a/src/Mapster/Adapters/NullableAdapter.cs b/src/Mapster/Adapters/NullableAdapter.cs index bfc7d836..598a2192 100644 --- a/src/Mapster/Adapters/NullableAdapter.cs +++ b/src/Mapster/Adapters/NullableAdapter.cs @@ -1,4 +1,5 @@ -using Mapster.Utils; +using Mapster.Models; +using Mapster.Utils; using System.Linq.Expressions; namespace Mapster.Adapters @@ -19,6 +20,22 @@ protected override bool CanInline(Expression source, Expression? destination, Co protected override Expression? CreateInlineExpression(Expression source, CompileArgument arg, bool IsRequiredOnly = false) { + if (arg.ExplicitMapping) + { + LambdaExpression? Convert = null; + + TypeAdapterRule? getsettings; + arg.Context.Config.RuleMap.TryGetValue(new TypeTuple(arg.SourceType, arg.DestinationType), out getsettings); + + if (getsettings != null) + if (arg.MapType == MapType.MapToTarget) + Convert = getsettings.Settings.ConverterToTargetFactory(arg); + else + Convert = getsettings.Settings.ConverterFactory(arg); + if (Convert != null) + return Convert.Apply(arg.MapType, source); + } + var _source = source.Type.IsNullable() ? Expression.Convert(source, source.Type.GetGenericArguments()[0]) : source; From c0021f98be38f8f2768a5bdfd3a8df9a18ef0524 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 29 Jun 2026 06:46:58 +0500 Subject: [PATCH 14/32] fix: add supporting nullable property to Ignore() setter setting from Nullable context analizer (#991) --- src/Mapster/TypeAdapterSetter.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mapster/TypeAdapterSetter.cs b/src/Mapster/TypeAdapterSetter.cs index b50933de..03c41eeb 100644 --- a/src/Mapster/TypeAdapterSetter.cs +++ b/src/Mapster/TypeAdapterSetter.cs @@ -402,7 +402,7 @@ internal TypeAdapterSetter(TypeAdapterSettings settings, TypeAdapterConfig paren : base(settings, parentConfig) { } - public TypeAdapterSetter Ignore(params Expression>[] members) + public TypeAdapterSetter Ignore(params Expression>[] members) { this.CheckCompiled(); @@ -568,12 +568,12 @@ internal TypeAdapterSetter(TypeAdapterSettings settings, TypeAdapterConfig paren #region replace for chaining - public new TypeAdapterSetter Ignore(params Expression>[] members) + public new TypeAdapterSetter Ignore(params Expression>[] members) { return (TypeAdapterSetter)base.Ignore(members); } - public TypeAdapterSetter IgnoredRemove(params Expression>[] members) + public TypeAdapterSetter IgnoredRemove(params Expression>[] members) { this.CheckCompiled(); @@ -1114,7 +1114,7 @@ public TwoWaysTypeAdapterSetter MapToConstructor(bool val return this; } - public TwoWaysTypeAdapterSetter Ignore(params Expression>[] members) + public TwoWaysTypeAdapterSetter Ignore(params Expression>[] members) { foreach (var member in members) { From 5d22318185c4a7e10e9a75fb50cd25a726635bd8 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 29 Jun 2026 06:52:54 +0500 Subject: [PATCH 15/32] chore: Bump version to v10.0.10 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index af102088..93a1e67c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.9 + 10.0.10 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0 From 72bbba378364fe8c16eea6c224cd2039c1fc1a86 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 14 Jul 2026 09:06:41 +0500 Subject: [PATCH 16/32] fix: Detecting a type without public constructors like NotSelfCreation type --- .../WhenMappingRecordRegression.cs | 18 ++++++++++++++++++ src/Mapster/Utils/ReflectionUtils.cs | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Mapster.Tests/WhenMappingRecordRegression.cs b/src/Mapster.Tests/WhenMappingRecordRegression.cs index bcf0c96e..1cf00d11 100644 --- a/src/Mapster.Tests/WhenMappingRecordRegression.cs +++ b/src/Mapster.Tests/WhenMappingRecordRegression.cs @@ -2,6 +2,7 @@ using Shouldly; using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using static Mapster.Tests.WhenExplicitMappingRequired; using static Mapster.Tests.WhenMappingDerived; @@ -562,6 +563,23 @@ public void NotSelfCreationTypeMappingInContainingClassWithoutError() uriDest.Uri.ToString().ShouldBe("https://www.google.com/"); } + /// + /// https://github.com/MapsterMapper/Mapster/issues/995 + /// + [TestMethod] + public void TypeWithOutPublicCtorDetectAsNotSelfCreationType() + { + var src = new CultureInfo("fr-FR"); + + Should.NotThrow(()=> + { + //src.Adapt(); + src.TextInfo.Adapt(); + }); + + } + + class SourceClassWithJsonDocument911 { public required JsonDocument Json { get; init; } diff --git a/src/Mapster/Utils/ReflectionUtils.cs b/src/Mapster/Utils/ReflectionUtils.cs index 36a792fc..8203858f 100644 --- a/src/Mapster/Utils/ReflectionUtils.cs +++ b/src/Mapster/Utils/ReflectionUtils.cs @@ -460,9 +460,11 @@ public static bool IsNotSelfCreation(this Type type) return false; if(type.IsCollectionCompatible()) return false; - + if (type.GetConstructors().Length == 0) + return true; if (type == typeof(Type) || type.BaseType == typeof(MulticastDelegate)) return true; + return type.GetFieldsAndProperties().All(it => (it.SetterModifier & (AccessModifier.Public | AccessModifier.NonPublic)) == 0); } From 4d7ad456bee0c38c247b01babbed01c7e83a77e0 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Wed, 15 Jul 2026 11:40:44 +0300 Subject: [PATCH 17/32] fix: #992 and #996 (#999) * feat: add git ignore * fix: fix apply Settings to Nullable types * feat(test): add test from #996 case * feat(test): add Custom Null converter test * fix: CustomConverterNull protection * fix: add Null check visitor * feat: Refactoring ApplyNullPropagation to CustomConverterFactory * feat: clean up * fix: drop not using * fix: remove clases --- .gitignore | 1 + .../WhenMappingNullablePrimitives.cs | 101 +++++++++++++++++- src/Mapster/Adapters/NullableAdapter.cs | 18 +--- src/Mapster/TypeAdapterConfig.cs | 8 +- src/Mapster/TypeAdapterSetter.cs | 29 ++++- src/Mapster/TypeAdapterSettings.cs | 5 + src/Mapster/Utils/NullCheckFinder.cs | 49 +++++++++ 7 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 src/Mapster/Utils/NullCheckFinder.cs diff --git a/.gitignore b/.gitignore index 434416f4..e6d5ee03 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,4 @@ src/.idea # VS Code settings .vscode/launch.json .vscode/tasks.json +.DS_Store diff --git a/src/Mapster.Tests/WhenMappingNullablePrimitives.cs b/src/Mapster.Tests/WhenMappingNullablePrimitives.cs index 47aa080d..bd7e3fcd 100644 --- a/src/Mapster.Tests/WhenMappingNullablePrimitives.cs +++ b/src/Mapster.Tests/WhenMappingNullablePrimitives.cs @@ -172,9 +172,95 @@ public void CustomConverterWorkWithNullablePrimitiveTypes() } + [TestMethod] + public void NullablePrimitiveTypesCorrectUsageNonNullableSettings() + { + TypeAdapterConfig config = new TypeAdapterConfig(); + config.NewConfig().MapWith(src => Convert.ToInt32(src)); + + long? value = null; + + Should.NotThrow(() => value.Adapt(config) ); + + } + + [TestMethod] + public void MapNullableValueTypePropertryToNonNullableCorrect() + { + TypeAdapterConfig config = new TypeAdapterConfig(); + config.NewConfig().MapWith(src => Convert.ToInt32(src)); + config.NewConfig() + .Map(dest => dest.IntNonNullable, src => src.LongNullable); + + var src = new PropSrc996 { LongNullable = 42 }; + var result = src.Adapt(config); + + var srcnull = new PropSrc996 { LongNullable = null }; + var resultnull = srcnull.Adapt(config); + + result.IntNonNullable.ShouldBe(42); + resultnull.IntNonNullable.ShouldBe(0); + + Should.Throw(() => new PropSrc996 { LongNullable = long.MaxValue }.Adapt(config)); + + } + + [TestMethod] + public void CustomNullConverterisWorked() + { + TypeAdapterConfig config = new TypeAdapterConfig(); + config.NewConfig().MapWith(src => src == null ? 996 : Convert.ToInt32(src.Value)); + config.NewConfig() + .Map(dest => dest.IntNonNullable, src => src.LongNullable); + + var src = new PropSrc996 { LongNullable = 42 }; + var result = src.Adapt(config); + + var srcnull = new PropSrc996 { LongNullable = null }; + var resultnull = srcnull.Adapt(config); + + result.IntNonNullable.ShouldBe(42); + resultnull.IntNonNullable.ShouldBe(996); + + Should.Throw(() => new PropSrc996 { LongNullable = long.MaxValue }.Adapt(config)); + + } + + [TestMethod] + public void ApplyNullPropagationToCustomConverterWorked() + { + TypeAdapterConfig config = new TypeAdapterConfig(); + config.ActivateCustomConvertersSrcNullPropagation = true; + config.ForType().MapWith(src => (long)Helper992.ToSecondsTimestamp(src)); // work only ParseToNullableBool() return not null + + var src = new NullableDateTimeInsaider(){ CreatedAt = null}; + Should.NotThrow(()=> src.Adapt(config)); + } + #region TestClasses + public class NullableDateTimeInsaider + { + public DateTime? CreatedAt { get; set; } + } + + public class LongInsaider + { + public long CreatedAt { get; set; } + } + + public class PropSrc996 + { + public long? LongNullable { get; set; } + } + + public class PropDest996 + { + public int IntNonNullable { get; set; } + } + + static class Helper992 { public static bool? ParseToNullableBool(string? value) @@ -189,13 +275,26 @@ static class Helper992 _ => null }; } + + public static long? ToSecondsTimestamp( DateTime? dateTime) + { + if (dateTime is null) + return null; + + return 42; + } + } public class Source992 { public string? Prop1 { get; set; } } - + public class Destination992NotNull + { + public bool Prop1 { get; set; } + } + public class Destination992 { public bool? Prop1 { get; set; } diff --git a/src/Mapster/Adapters/NullableAdapter.cs b/src/Mapster/Adapters/NullableAdapter.cs index 598a2192..178c3266 100644 --- a/src/Mapster/Adapters/NullableAdapter.cs +++ b/src/Mapster/Adapters/NullableAdapter.cs @@ -11,6 +11,8 @@ internal class NullableAdapter : BaseAdapter protected override bool CanMap(PreCompileArgument arg) { + if(arg.ExplicitMapping) + return false; return arg.SourceType.IsNullable() || arg.DestinationType.IsNullable(); } protected override bool CanInline(Expression source, Expression? destination, CompileArgument arg) @@ -20,22 +22,6 @@ protected override bool CanInline(Expression source, Expression? destination, Co protected override Expression? CreateInlineExpression(Expression source, CompileArgument arg, bool IsRequiredOnly = false) { - if (arg.ExplicitMapping) - { - LambdaExpression? Convert = null; - - TypeAdapterRule? getsettings; - arg.Context.Config.RuleMap.TryGetValue(new TypeTuple(arg.SourceType, arg.DestinationType), out getsettings); - - if (getsettings != null) - if (arg.MapType == MapType.MapToTarget) - Convert = getsettings.Settings.ConverterToTargetFactory(arg); - else - Convert = getsettings.Settings.ConverterFactory(arg); - if (Convert != null) - return Convert.Apply(arg.MapType, source); - } - var _source = source.Type.IsNullable() ? Expression.Convert(source, source.Type.GetGenericArguments()[0]) : source; diff --git a/src/Mapster/TypeAdapterConfig.cs b/src/Mapster/TypeAdapterConfig.cs index 9463072b..08db194a 100644 --- a/src/Mapster/TypeAdapterConfig.cs +++ b/src/Mapster/TypeAdapterConfig.cs @@ -89,6 +89,7 @@ private static List CreateRuleTemplate() public bool AllowImplicitDestinationInheritance { get; set; } public bool AllowImplicitSourceInheritance { get; set; } = true; public bool SelfContainedCodeGeneration { get; set; } + public bool ActivateCustomConvertersSrcNullPropagation { get; set; } = false; public Func Compiler { get; set; } = lambda => lambda.Compile(); @@ -269,7 +270,7 @@ private static TypeAdapterRule CreateDestinationTypeRule(TypeTuple key) private static int? GetSubclassDistance(Type type1, Type type2, bool allowInheritance) { //Support for using ValueType mapping configurations of types, for mapping cases on Nulllable ValueType values - if (type1.IsNullable() && !type1.ContainsGenericParameters) + if (type2.IsInterface && type1.IsNullable() && !type1.ContainsGenericParameters) type1 = type1.GetGenericArguments().FirstOrDefault(); if (type1 == type2) @@ -447,6 +448,10 @@ private static LambdaExpression CreateMapExpression(CompileArgument arg) private static LambdaExpression AdjustInheritedConverterReturnType(LambdaExpression lambda, CompileArgument arg) { + + if(arg.Settings.ApplyCustomConverterFactoryNullPropagation.GetValueOrDefault()) + lambda = Expression.Lambda(lambda.Parameters[0].NotNullReturn(lambda.Body),lambda.Parameters); + var destinationType = arg.DestinationType; var returnType = lambda.ReturnType; var lamdaBody = lambda.Body; @@ -501,7 +506,6 @@ private static LambdaExpression AdjustInheritedConverterReturnType(LambdaExpress } - return Expression.Lambda(body, lambda.Parameters); } diff --git a/src/Mapster/TypeAdapterSetter.cs b/src/Mapster/TypeAdapterSetter.cs index 03c41eeb..7a78af01 100644 --- a/src/Mapster/TypeAdapterSetter.cs +++ b/src/Mapster/TypeAdapterSetter.cs @@ -714,7 +714,8 @@ public TypeAdapterSetter ConstructUsing(Expression MapWith(Expression> converterFactory, bool applySettings = false) + public TypeAdapterSetter MapWith(Expression> converterFactory, bool applySettings = false, + bool disableCustomConvertersSrcNullPropagation = false) { this.CheckCompiled(); @@ -734,10 +735,22 @@ public TypeAdapterSetter MapWith(Expression MapToTargetWith(Expression> converterFactory, bool applySettings = false) + public TypeAdapterSetter MapToTargetWith(Expression> converterFactory, bool applySettings = false, + bool disableCustomConvertersSrcNullPropagation = false) { this.CheckCompiled(); @@ -753,6 +766,18 @@ public TypeAdapterSetter MapToTargetWith(Expression converterFactory; + + if (converterFactory.Parameters[0].Type.CanBeNull() + && Config.ActivateCustomConvertersSrcNullPropagation + && !disableCustomConvertersSrcNullPropagation) + { + var check = new NullCheckFinder(converterFactory.Parameters[0]); + check.Visit(converterFactory.Body); + + if (!check.FoundNullCheck) + Settings.ApplyCustomConverterFactoryNullPropagation = true; + } + return this; } diff --git a/src/Mapster/TypeAdapterSettings.cs b/src/Mapster/TypeAdapterSettings.cs index 38e3a53c..65f8b6e4 100644 --- a/src/Mapster/TypeAdapterSettings.cs +++ b/src/Mapster/TypeAdapterSettings.cs @@ -181,6 +181,11 @@ public Func? ConverterToTargetFactory get => Get>(nameof(ConverterToTargetFactory)); set => Set(nameof(ConverterToTargetFactory), value); } + public bool? ApplyCustomConverterFactoryNullPropagation + { + get => Get(nameof(ApplyCustomConverterFactoryNullPropagation)); + set => Set(nameof(ApplyCustomConverterFactoryNullPropagation), value); + } public object? MapToConstructor { get => Get(nameof(MapToConstructor)); diff --git a/src/Mapster/Utils/NullCheckFinder.cs b/src/Mapster/Utils/NullCheckFinder.cs new file mode 100644 index 00000000..ae1d302f --- /dev/null +++ b/src/Mapster/Utils/NullCheckFinder.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq.Expressions; + +public class NullCheckFinder : ExpressionVisitor +{ + private readonly ParameterExpression _targetParameter; + public bool FoundNullCheck { get; private set; } + + public NullCheckFinder(ParameterExpression targetParameter) + { + _targetParameter = targetParameter ?? throw new ArgumentNullException(nameof(targetParameter)); + FoundNullCheck = false; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + + if (node.NodeType == ExpressionType.Equal || node.NodeType == ExpressionType.NotEqual) + { + + var isLeftTarget = IsSameParameter(node.Left); + var isRightTarget = IsSameParameter(node.Right); + + + var otherSideIsNull = + (!isLeftTarget && IsNullConstant(node.Left)) || + (!isRightTarget && IsNullConstant(node.Right)); + + if ((isLeftTarget || isRightTarget) && otherSideIsNull) + { + FoundNullCheck = true; + + return node; + } + } + + return base.VisitBinary(node); + } + + private bool IsSameParameter(Expression exp) + { + return exp is ParameterExpression param && param == _targetParameter; + } + + private static bool IsNullConstant(Expression exp) + { + return exp is ConstantExpression c && c.Value == null; + } +} \ No newline at end of file From a23e9be19ffafac27782a402534a6addd3db2ccb Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Wed, 15 Jul 2026 11:43:45 +0300 Subject: [PATCH 18/32] chore: Bump version to v10.0.11 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1554b7bb..7b0f4082 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.10 + 10.0.11 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0 From de287c57fab95b8a1365b864784dd4802a51fddd Mon Sep 17 00:00:00 2001 From: alihut Date: Wed, 19 Aug 2026 13:49:37 +0300 Subject: [PATCH 19/32] fix: apply IgnoreIf to record properties outside the primary constructor --- .../WhenIgnoringConditionally.cs | 43 +++++++++++++++++++ src/Mapster/Adapters/RecordTypeAdapter.cs | 22 ++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/Mapster.Tests/WhenIgnoringConditionally.cs b/src/Mapster.Tests/WhenIgnoringConditionally.cs index 9014d133..b8292609 100644 --- a/src/Mapster.Tests/WhenIgnoringConditionally.cs +++ b/src/Mapster.Tests/WhenIgnoringConditionally.cs @@ -172,6 +172,43 @@ public void IgnoreIf_Apply_To_RecordType() dto.Name.ShouldBeNull(); } + // Regression test for https://github.com/MapsterMapper/Mapster/issues/1007 + // Unlike SimpleRecord above (which maps every member through the primary + // constructor), NativeRecord is a real C# `record` whose Name property is + // NOT part of the primary constructor — it's a plain settable auto-property. + // Members mapped that way go through RecordTypeAdapter.RecordInlineExpression, + // a separate code path from constructor-argument mapping, which used to + // ignore member.Ignore.Condition entirely. + [TestMethod] + public void IgnoreIf_Apply_To_RecordType_Property_Not_In_Constructor_Map() + { + TypeAdapterConfig.NewConfig() + .IgnoreIf((src, dest) => src.Name == "TestName", dest => dest.Name) + .Compile(); + + var poco = new SimplePoco { Id = 1, Name = "TestName" }; + var dto = TypeAdapter.Adapt(poco); + + dto.Id.ShouldBe(1); + dto.Name.ShouldBeNull(); + } + + [TestMethod] + public void IgnoreIf_Apply_To_RecordType_Property_Not_In_Constructor_MapToTarget() + { + TypeAdapterConfig.NewConfig() + .IgnoreIf((src, dest) => src.Name == "TestName", dest => dest.Name) + .Compile(); + + var poco = new SimplePoco { Id = 1, Name = "TestName" }; + var dto = new NativeRecord { Id = 999, Name = "DtoName" }; + + var result = TypeAdapter.Adapt(poco, dto); + + result.Id.ShouldBe(1); + result.Name.ShouldBe("DtoName"); + } + #endregion @@ -202,6 +239,12 @@ public SimpleRecord(int id, string name) } } + public record NativeRecord + { + public int Id { get; set; } + public string Name { get; set; } + } + #endregion } diff --git a/src/Mapster/Adapters/RecordTypeAdapter.cs b/src/Mapster/Adapters/RecordTypeAdapter.cs index 63c32747..ab318caa 100644 --- a/src/Mapster/Adapters/RecordTypeAdapter.cs +++ b/src/Mapster/Adapters/RecordTypeAdapter.cs @@ -103,6 +103,28 @@ protected override Expression CreateInstantiationExpression(Expression source, E } } + // IgnoreIf(...) support — unlike ClassAdapter (which drops the whole + // member statement into an `if (!condition) { ... }` block), record + // members are bound inline inside a MemberInit, so the condition is + // expressed as a ternary: keep the computed value when the condition + // is false, otherwise fall back to whatever the member should be left + // as (the existing destination value for MapToTarget, or the type's + // default value when constructing a brand-new instance). + if (arg.MapType != MapType.Projection && member.Ignore.Condition != null) + { + var conditionDestination = destination ?? arg.DestinationType.CreateDefault(); + var conditionBody = member.Ignore.IsChildPath + ? member.Ignore.Condition.Body + : member.Ignore.Condition.Apply(arg.MapType, source, conditionDestination); + var notIgnored = ExpressionEx.Not(conditionBody); + + var fallback = arg.MapType == MapType.MapToTarget && destination != null + ? member.DestinationMember.GetExpression(destination) + : member.DestinationMember.Type.CreateDefault(); + + adapt = Expression.Condition(notIgnored, adapt, fallback); + } + //special null property check for projection //if we don't set null to property, EF will create empty object //except collection type & complex type which cannot be null From 1f3fe109d314968387992c87a9cb115b5b11af9f Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Wed, 19 Aug 2026 17:46:02 +0500 Subject: [PATCH 20/32] chore: Bump version to v10.0.12 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7b0f4082..99ef89c6 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.11 + 10.0.12 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0 From 0507d0a9020a4bde3ea6be673bd97c94dcf3364c Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 25 Aug 2026 15:32:55 +0500 Subject: [PATCH 21/32] chore: Improvements for Mapster Tool (#1016) * feat: Improvements for MapsterTool GenerateMappers Create Explicit implementation from Mappers interfaces * feat: add GeneratedMapperAttribute and test * feat: add helpers file created param * feat: add random * feat: add supported generate mappers for internal interfaces and public interfaces with internal member and test refactoring * fix: refactoring attribute impl * fix: skip explicit interface implimentation if using only public way: 1) property or method maks as public 2) property or method using only public clases 3) not using IsInternal atrribute * fix: refactoring RandomNamespaceGenerator - Added feature to deterministically generate a valid namespace based on an invalid string * fix: refactoring helpers generation --- .../ExpressionTranslator.cs | 97 +++++++++- .../IGeneratedAttribute.cs | 11 ++ .../MapsterToolGeneratedMapperAttribute.cs | 38 ++++ .../Helpers/GeneratedBase.cs | 18 ++ .../Helpers/MemberInfoExtensions.cs | 33 ++++ .../Helpers/RandomNamespaceGenerator.cs | 80 ++++++++ src/ExpressionTranslator/TypeDefinitions.cs | 4 +- src/Mapster.Tool/MapperOptions.cs | 7 + src/Mapster.Tool/Program.cs | 49 +++-- src/TemplateTest/CreateMapExpressionTest.cs | 172 ++++++++++++++++++ 10 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 src/ExpressionTranslator/Helpers/GeneratedAttributes/IGeneratedAttribute.cs create mode 100644 src/ExpressionTranslator/Helpers/GeneratedAttributes/MapsterToolGeneratedMapperAttribute.cs create mode 100644 src/ExpressionTranslator/Helpers/GeneratedBase.cs create mode 100644 src/ExpressionTranslator/Helpers/MemberInfoExtensions.cs create mode 100644 src/ExpressionTranslator/Helpers/RandomNamespaceGenerator.cs diff --git a/src/ExpressionTranslator/ExpressionTranslator.cs b/src/ExpressionTranslator/ExpressionTranslator.cs index 6c219f86..823bebcc 100644 --- a/src/ExpressionTranslator/ExpressionTranslator.cs +++ b/src/ExpressionTranslator/ExpressionTranslator.cs @@ -11,6 +11,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; +using System.Xml.Linq; namespace ExpressionDebugger { @@ -1268,6 +1269,88 @@ public Expression VisitLambda(LambdaExpression node, LambdaType type, string? me } } + public Expression VisitLambdaForGenerateMappers(LambdaExpression node, LambdaType type, Type InterfaceType, string? methodName = null, + bool isInternal = false) + { + VisitLambda(node, type, methodName, isInternal); + + if (!isInternal) + isInternal = node.ReturnType.GetTypeInfo().IsNotPublic || + node.Parameters.Any(it => it.Type.GetTypeInfo().IsNotPublic); + + if(!isInternal) + return node; // skip create interface implimentation if public only + + if (type == LambdaType.PrivateLambda || type == LambdaType.PublicLambda) + { + _inlineCount++; + if (type == LambdaType.PublicLambda) + { + var name = methodName != null ? $"{InterfaceType.FullName}.{methodName}" : "Main"; + WriteLine(); + var funcType = MakeDelegateType(node.ReturnType, node.Parameters.Select(it => it.Type).ToArray()); + var exprType = typeof(Expression<>).MakeGenericType(funcType); + Write(Translate(exprType), " ", name, " => "); + } + + IList args; + if (node.Parameters.Count == 1) + { + args = new List(); + var arg = VisitParameter(node.Parameters[0]); + args.Add((ParameterExpression)arg); + } + else + { + args = VisitArguments("(", node.Parameters.ToList(), p => (ParameterExpression)VisitParameter(p), + ")"); + } + + Write(" => "); + var body = VisitGroup(node.Body, ExpressionType.Quote); + if (type == LambdaType.PublicLambda) + Write(";"); + _inlineCount--; + return Expression.Lambda(body, node.Name, node.TailCall, args); + } + else + { + var name = methodName != null ? $"{InterfaceType.FullName}.{methodName}" : "Main"; + if (type == LambdaType.PublicMethod || type == LambdaType.ExtensionMethod) + { + if (!isInternal) + isInternal = node.ReturnType.GetTypeInfo().IsNotPublic || + node.Parameters.Any(it => it.Type.GetTypeInfo().IsNotPublic); + WriteLine(); + Methods[name] = node.Type; + } + else + { + name = GetName(node, name); + WriteModifierNextLine("private"); + } + + Write(Translate(node.ReturnType), " ", name); + var open = "("; + if (type == LambdaType.ExtensionMethod) + { + if (Definitions?.IsStatic != true) + throw new InvalidOperationException("Extension method requires static class"); + if (node.Parameters.Count == 0) + throw new InvalidOperationException("Extension method requires at least 1 parameter"); + open = "(this "; + } + + var args = VisitArguments(open, node.Parameters, VisitParameterDeclaration, ")"); + Indent(); + var body = VisitBody(node.Body, true); + + Outdent(); + + return Expression.Lambda(body, name, node.TailCall, args); + } + } + private HashSet? _visitedLambda; private int _writerLevel; @@ -1865,9 +1948,16 @@ public override string ToString() WriteNextLine("using ", ns, ";"); } - WriteLine(); } + foreach (var ns in Definitions.GeneratedAttributes.Select(x => x.NameSpace).Distinct()) + { + WriteNextLine("using ", ns, ";"); + } + + if(_usings != null || Definitions.GeneratedAttributes.Count != 0) + WriteLine(); + // NOTE: type alias cannot solve all name conflicted case, user should use PrintFullTypeName // keep logic here for compatibility if (_typeNames != null) @@ -1891,6 +1981,11 @@ public override string ToString() Indent(); } + foreach (var gAttr in Definitions.GeneratedAttributes) + { + WriteNextLine(gAttr.Implimentation); + } + var isInternal = Definitions.IsInternal; if (!isInternal) isInternal = Definitions.Implements?.Any(it => diff --git a/src/ExpressionTranslator/Helpers/GeneratedAttributes/IGeneratedAttribute.cs b/src/ExpressionTranslator/Helpers/GeneratedAttributes/IGeneratedAttribute.cs new file mode 100644 index 00000000..87803168 --- /dev/null +++ b/src/ExpressionTranslator/Helpers/GeneratedAttributes/IGeneratedAttribute.cs @@ -0,0 +1,11 @@ +namespace ExpressionDebugger.Helpers.GeneratedAttributes +{ + public interface IGeneratedAttribute + { + public string NameSpace { get;} + public string Declaration { get;} + public string Implimentation { get; } + public string FileName { get;} + + } +} diff --git a/src/ExpressionTranslator/Helpers/GeneratedAttributes/MapsterToolGeneratedMapperAttribute.cs b/src/ExpressionTranslator/Helpers/GeneratedAttributes/MapsterToolGeneratedMapperAttribute.cs new file mode 100644 index 00000000..c2b3d9fc --- /dev/null +++ b/src/ExpressionTranslator/Helpers/GeneratedAttributes/MapsterToolGeneratedMapperAttribute.cs @@ -0,0 +1,38 @@ +using System; +using System.Text; +using static ExpressionDebugger.Helpers.RandomNamespaceGenerator; + +namespace ExpressionDebugger.Helpers.GeneratedAttributes +{ + public class MapsterToolGeneratedMapperAttribute : GeneratedBase, IGeneratedAttribute + { + private readonly StringBuilder _Declaration; + private readonly string _NameSpace; + + public string NameSpace => _NameSpace; + + public string Declaration => _Declaration.ToString(); + + public string Implimentation => "[MapsterToolGeneratedMapper]"; + + public string FileName => "MapsterToolGeneratedMapperAttribute"; + + public MapsterToolGeneratedMapperAttribute(string extendedNameSpace) + { + if (String.IsNullOrEmpty(extendedNameSpace)) + throw new ArgumentNullException("Extended namespace not specified or is null/empty string"); + + if(CheckNameSpace.IsMatch(extendedNameSpace)) + _NameSpace = $"Mapster.Generated.Attributes.{extendedNameSpace}"; + else + _NameSpace = $"Mapster.Generated.Attributes.{Generate(extendedNameSpace,1,1)}"; + + _Declaration = new StringBuilder(); + + _Declaration.Append("using System;\r\n\r\n"); + _Declaration.Append($"namespace {NameSpace}"); + _Declaration.Append("\r\n{\r\n public sealed class MapsterToolGeneratedMapperAttribute : Attribute\r\n {\r\n\r\n }\r\n} "); + } + + } +} diff --git a/src/ExpressionTranslator/Helpers/GeneratedBase.cs b/src/ExpressionTranslator/Helpers/GeneratedBase.cs new file mode 100644 index 00000000..4be95395 --- /dev/null +++ b/src/ExpressionTranslator/Helpers/GeneratedBase.cs @@ -0,0 +1,18 @@ +namespace ExpressionDebugger.Helpers +{ + public abstract class GeneratedBase + { + public override bool Equals(object obj) + { + if(obj is null) + return base.Equals(obj); + else + return this.GetType() == obj.GetType(); + } + + public override int GetHashCode() + { + return this.GetType().GetHashCode(); + } + } +} diff --git a/src/ExpressionTranslator/Helpers/MemberInfoExtensions.cs b/src/ExpressionTranslator/Helpers/MemberInfoExtensions.cs new file mode 100644 index 00000000..b3a1782c --- /dev/null +++ b/src/ExpressionTranslator/Helpers/MemberInfoExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; + +namespace ExpressionDebugger.Helpers +{ + public static class MemberInfoExtensions + { + public static bool IsPublicOrInternal(this MethodInfo method) + { + if (method == null) throw new ArgumentNullException(nameof(method)); + + return !method.IsPrivate + && !method.IsFamily + && !method.IsFamilyOrAssembly + && !method.IsFamilyAndAssembly + && (method.IsPublic || true); + } + + + + public static bool IsGetterPublicOrInternal(this PropertyInfo property) + { + if (property == null) throw new ArgumentNullException(nameof(property)); + + MethodInfo? getMethod = property.GetMethod; + + if (getMethod == null) return false; + + return getMethod.IsPublicOrInternal(); + } + } + +} diff --git a/src/ExpressionTranslator/Helpers/RandomNamespaceGenerator.cs b/src/ExpressionTranslator/Helpers/RandomNamespaceGenerator.cs new file mode 100644 index 00000000..d02cc84e --- /dev/null +++ b/src/ExpressionTranslator/Helpers/RandomNamespaceGenerator.cs @@ -0,0 +1,80 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace ExpressionDebugger.Helpers +{ + public static class RandomNamespaceGenerator + { + public static readonly Regex CheckNameSpace = new Regex(@"^([a-zA-Z_]\w*)(\.[a-zA-Z_]\w*)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private const string Consonants = "bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ"; + private const string Vowels = "aeiouAEIOU"; + private const string Digits = "0123456789"; + + public static string Generate(string input, int minParts = 2, int maxParts = 4) + { + if (string.IsNullOrEmpty(input)) throw new ArgumentException("Input cannot be empty."); + if (minParts < 1) minParts = 1; + if (maxParts < minParts) maxParts = minParts; + + using var sha256 = SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); + + long seed = BitConverter.ToInt64(hashBytes, 0); + var random = new Random(unchecked((int)seed ^ (int)(seed >> 32))); + + int partsCount = random.Next(minParts, maxParts + 1); + var sb = new StringBuilder(); + + for (int i = 0; i < partsCount; i++) + { + if (i > 0) sb.Append('.'); + sb.Append(GeneratePart(random)); + } + + return sb.ToString(); + } + + + public static string Generate(int minParts = 2, int maxParts = 4) + { + if (minParts < 1) minParts = 1; + if (maxParts < minParts) maxParts = minParts; + + var _random = new Random(); + + int partsCount = _random.Next(minParts, maxParts + 1); + var sb = new StringBuilder(); + + for (int i = 0; i < partsCount; i++) + { + if (i > 0) sb.Append('.'); + sb.Append(GeneratePart(_random)); + } + + return sb.ToString(); + } + + private static string GeneratePart(Random random, int minLength = 2, int maxLength = 10) + { + if (minLength < 1) minLength = 1; + if (maxLength < minLength) maxLength = minLength; + + int length = random.Next(minLength, maxLength + 1); + var sb = new StringBuilder(length); + + sb.Append(Consonants[random.Next(Consonants.Length)]); + + for (int i = 1; i < length; i++) + { + string pool = (i % 2 == 0) ? Vowels : Consonants; + if (random.NextDouble() < 0.1) pool = Digits; + sb.Append(pool[random.Next(pool.Length)]); + } + + return sb.ToString(); + } + } +} + diff --git a/src/ExpressionTranslator/TypeDefinitions.cs b/src/ExpressionTranslator/TypeDefinitions.cs index a8e20667..ec730304 100644 --- a/src/ExpressionTranslator/TypeDefinitions.cs +++ b/src/ExpressionTranslator/TypeDefinitions.cs @@ -1,4 +1,5 @@ -using System; +using ExpressionDebugger.Helpers.GeneratedAttributes; +using System; using System.Collections.Generic; namespace ExpressionDebugger @@ -12,6 +13,7 @@ public class TypeDefinitions public IEnumerable? Implements { get; set; } public bool PrintFullTypeName { get; set; } public bool IsRecordType { get; set; } + public HashSet GeneratedAttributes { get; set; } = new HashSet(); /// /// Set to 2 to mark all properties as nullable diff --git a/src/Mapster.Tool/MapperOptions.cs b/src/Mapster.Tool/MapperOptions.cs index 9cd8a1aa..ce0b6fc5 100644 --- a/src/Mapster.Tool/MapperOptions.cs +++ b/src/Mapster.Tool/MapperOptions.cs @@ -28,6 +28,13 @@ public class MapperOptions [Option('N', "nullableDirective", Required = false, HelpText = "Set true to add \"#nullable enable\" to the top of generated mapper files")] public bool GenerateNullableDirective { get; set; } + [Option('h', "helpersCreate", Required = false, HelpText = "Generate helpers features")] + public bool CreateHelpers { get; set; } + + [Option('H', "helpersNamespace", Required = false, HelpText = "Specify additional namespace to generated helpers features")] + public string? HelpersNamespace { get; set; } + + [Usage(ApplicationAlias = "dotnet mapster mapper")] public static IEnumerable Examples => new List diff --git a/src/Mapster.Tool/Program.cs b/src/Mapster.Tool/Program.cs index 1347cb13..e93ce4d4 100644 --- a/src/Mapster.Tool/Program.cs +++ b/src/Mapster.Tool/Program.cs @@ -1,4 +1,10 @@ -using System; +using CommandLine; +using ExpressionDebugger; +using ExpressionDebugger.Helpers; +using ExpressionDebugger.Helpers.GeneratedAttributes; +using Mapster.Models; +using Mapster.Utils; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -6,10 +12,6 @@ using System.Reflection; using System.Runtime.Loader; using System.Text; -using CommandLine; -using ExpressionDebugger; -using Mapster.Models; -using Mapster.Utils; namespace Mapster.Tool { @@ -91,6 +93,14 @@ private static void GenerateMappers(MapperOptions opt) config.SelfContainedCodeGeneration = true; config.Scan(assembly); + var generatedAtrr = new List(); + + if (opt.CreateHelpers) + generatedAtrr.Add(new MapsterToolGeneratedMapperAttribute( + opt.HelpersNamespace ?? Path.GetFileNameWithoutExtension(opt.Assembly) + )); + + foreach (var type in assembly.GetLoadableTypes()) { if (!type.IsInterface) @@ -109,8 +119,11 @@ private static void GenerateMappers(MapperOptions opt) TypeName = attr.Name ?? GetImplName(GetCodeFriendlyTypeName(type)), IsInternal = attr.IsInternal, PrintFullTypeName = opt.PrintFullTypeName, + GeneratedAttributes = new(generatedAtrr) }; + bool? _isForceInternal = definitions.IsInternal ? true : null; + var path = GetOutput(opt.Output, segments, definitions.TypeName); if (opt.SkipExistingFiles && File.Exists(path)) { @@ -124,7 +137,9 @@ private static void GenerateMappers(MapperOptions opt) var interfaces = type.GetAllInterfaces(); foreach (var @interface in interfaces) { - foreach (var prop in @interface.GetProperties()) + foreach (var prop in @interface.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsGetterPublicOrInternal()) + ) { if (!prop.PropertyType.IsGenericType) continue; @@ -138,17 +153,21 @@ private static void GenerateMappers(MapperOptions opt) var funcArgs = propArgs.GetGenericArguments(); var tuple = new TypeTuple(funcArgs[0], funcArgs[1]); var expr = config.CreateMapExpression(tuple, MapType.Projection); - translator.VisitLambda( + translator.VisitLambdaForGenerateMappers( expr, ExpressionTranslator.LambdaType.PublicLambda, - prop.Name + @interface, + prop.Name, + _isForceInternal ?? (!prop.GetMethod?.IsPublic ?? false) ); } } foreach (var @interface in interfaces) { - foreach (var method in @interface.GetMethods()) + foreach (var method in @interface.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsPublicOrInternal()) + ) { if (method.IsGenericMethod) continue; @@ -162,10 +181,12 @@ private static void GenerateMappers(MapperOptions opt) tuple, methodArgs.Length == 1 ? MapType.Map : MapType.MapToTarget ); - translator.VisitLambda( + translator.VisitLambdaForGenerateMappers( expr, ExpressionTranslator.LambdaType.PublicMethod, - method.Name + @interface, + method.Name, + _isForceInternal ?? !method.IsPublic ); } } @@ -175,6 +196,12 @@ private static void GenerateMappers(MapperOptions opt) : translator.ToString(); WriteFile(code, path); } + + + foreach (var item in generatedAtrr) + { + WriteFile(item.Declaration, GetOutput(opt.Output, null, item.FileName)); + } } private static string GetImplName(string name) diff --git a/src/TemplateTest/CreateMapExpressionTest.cs b/src/TemplateTest/CreateMapExpressionTest.cs index 4929bb12..d71ab9d7 100644 --- a/src/TemplateTest/CreateMapExpressionTest.cs +++ b/src/TemplateTest/CreateMapExpressionTest.cs @@ -1,7 +1,14 @@ using ExpressionDebugger; +using ExpressionDebugger.Helpers; +using ExpressionDebugger.Helpers.GeneratedAttributes; using Mapster; +using Mapster.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; namespace TemplateTest { @@ -64,6 +71,107 @@ public void TestCreateProjectionExpression() Assert.IsNotNull(code); } + + /// + /// https://github.com/MapsterMapper/Mapster/issues/399 + /// + [TestMethod] + public void TestRegressionMapperGenerationTranslation() + { + var S = new MapsterToolGeneratedMapperAttribute("Test"); + + var config = new TypeAdapterConfig(); + config.SelfContainedCodeGeneration = true; + + var definitions = new TypeDefinitions + { + Implements = new[] { typeof(IMyTypeMapper), typeof(IMyTypeMapperIntenal) }, + Namespace = "Benchmark", + TypeName = "CustomerMapper", + IsInternal = false, + GeneratedAttributes = new(new[] {new MapsterToolGeneratedMapperAttribute("Test") }) + }; + + var translator = new ExpressionTranslator(definitions); + + translator.CreateFromInterface(definitions, config); + + var code = translator.ToString(); + + Assert.IsTrue(code.Contains("public partial class CustomerMapper")); // mapper class is public + Assert.IsTrue(code.Contains("AddressDTO TemplateTest.IMyTypeMapper.Map")); + Assert.IsTrue(code.Contains("[MapsterToolGeneratedMapper]")); + + Assert.IsTrue(code.Contains("internal AddressDTO Map")); // create internal method in public interface + + // create as internal because declarate in internal interface and using internal type AddressInternal + Assert.IsTrue(code.Contains("internal AddressInternal MapInternal")); + Assert.IsTrue(code.Contains("internal Expression> ProjectionInternal")); + + + Assert.IsTrue(code.Contains("public AddressDTO MapPublicClassInInternalInterface")); // create public method in internal interface because using public types + + // method using public types in internal interface but marked as internal create as internal method + Assert.IsTrue(code.Contains("internal AddressDTO MapPublicClassInInternalInterfaceWithMarkInternal")); + } + + [TestMethod] + public void CreateForceInternalMapper() + { + var config = new TypeAdapterConfig(); + config.SelfContainedCodeGeneration = true; + + var definitions = new TypeDefinitions + { + Implements = new[] { typeof(IMyTypeMapperForce)}, + Namespace = "Benchmark", + TypeName = "CustomerMapper", + IsInternal = true, // force create internal mapper + GeneratedAttributes = new(new[] { new MapsterToolGeneratedMapperAttribute("Test") }) + }; + + var translator = new ExpressionTranslator(definitions); + + translator.CreateFromInterface(definitions, config); + + var code = translator.ToString(); + + Assert.IsTrue(code.Contains("internal partial class CustomerMapper")); // mapper class is internal + + // force create internal method using only public types because mapper class is internal + Assert.IsTrue(code.Contains("internal AddressDTO Map")); + } + + + + } + + + public interface IMyTypeMapper + { + internal AddressDTO Map(Address p1); + public Expression> Projection { get; } + } + + internal interface IMyTypeMapperIntenal + { + AddressInternal MapInternal(Address p1); + Expression> ProjectionInternal { get; } + AddressDTO MapPublicClassInInternalInterface(Address p1); + internal AddressDTO MapPublicClassInInternalInterfaceWithMarkInternal(Address p1); + } + + public interface IMyTypeMapperForce + { + AddressDTO Map(Address p1); + } + + internal class AddressInternal + { + public int Id { get; set; } + public string Street { get; set; } + public string City { get; set; } + public string Country { get; set; } } public class Address @@ -102,4 +210,68 @@ public class CustomerDTO public List WorkAddresses { get; set; } public string AddressCity { get; set; } } + + static class GenerateMappersExtensions + { + public static void CreateFromInterface(this ExpressionTranslator translator, TypeDefinitions definitions, TypeAdapterConfig config) + { + if (definitions.Implements == null) + return; + + foreach (var interfaceType in definitions.Implements) + { + bool? _isForceInternal = definitions.IsInternal ? true : null; + + foreach (var method in interfaceType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsPublicOrInternal()) + ) + { + if (method.IsGenericMethod) + continue; + if (method.ReturnType == typeof(void)) + continue; + var methodArgs = method.GetParameters(); + if (methodArgs.Length < 1 || methodArgs.Length > 2) + continue; + var tuple = new TypeTuple(methodArgs[0].ParameterType, method.ReturnType); + var expr = config.CreateMapExpression( + tuple, + methodArgs.Length == 1 ? MapType.Map : MapType.MapToTarget + ); + translator.VisitLambdaForGenerateMappers( + expr, + ExpressionTranslator.LambdaType.PublicMethod, + interfaceType, + method.Name, + _isForceInternal ?? !method.IsPublic + ); + } + + foreach (var prop in interfaceType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsGetterPublicOrInternal()) + ) + { + if (!prop.PropertyType.IsGenericType) + continue; + if (prop.PropertyType.GetGenericTypeDefinition() != typeof(Expression<>)) + continue; + var propArgs = prop.PropertyType.GetGenericArguments()[0]; + if (!propArgs.IsGenericType) + continue; + if (propArgs.GetGenericTypeDefinition() != typeof(Func<,>)) + continue; + var funcArgs = propArgs.GetGenericArguments(); + var tuple = new TypeTuple(funcArgs[0], funcArgs[1]); + var expr = config.CreateMapExpression(tuple, MapType.Projection); + translator.VisitLambdaForGenerateMappers( + expr, + ExpressionTranslator.LambdaType.PublicLambda, + interfaceType, + prop.Name, + _isForceInternal ?? (!prop.GetMethod?.IsPublic ?? false) + ); + } + } + } + } } \ No newline at end of file From e8d329588473793d9b75ab80d3d11fed1680c613 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 25 Aug 2026 15:35:53 +0500 Subject: [PATCH 22/32] chore: Bump version to v10.0.13-pre02 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 99ef89c6..fcff4b97 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.12 + 10.0.13-pre02 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0 From 855537e9e0b2def6d823b68823a31e4d4f9b4418 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 08:55:18 +0500 Subject: [PATCH 23/32] chore: --Duplicate .\Program.cs history into .\Generators.cs --- src/Mapster.Tool/{Program.cs => Generators.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Mapster.Tool/{Program.cs => Generators.cs} (100%) diff --git a/src/Mapster.Tool/Program.cs b/src/Mapster.Tool/Generators.cs similarity index 100% rename from src/Mapster.Tool/Program.cs rename to src/Mapster.Tool/Generators.cs From df8439e737d5f8941d1a72275e5d0fa51805a162 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 08:55:18 +0500 Subject: [PATCH 24/32] chore: --Restore .\Program.cs --- src/Mapster.Tool/Program.cs | 741 ++++++++++++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 src/Mapster.Tool/Program.cs diff --git a/src/Mapster.Tool/Program.cs b/src/Mapster.Tool/Program.cs new file mode 100644 index 00000000..e93ce4d4 --- /dev/null +++ b/src/Mapster.Tool/Program.cs @@ -0,0 +1,741 @@ +using CommandLine; +using ExpressionDebugger; +using ExpressionDebugger.Helpers; +using ExpressionDebugger.Helpers.GeneratedAttributes; +using Mapster.Models; +using Mapster.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.Loader; +using System.Text; + +namespace Mapster.Tool +{ + class Program + { + static void Main(string[] args) + { + Parser.Default + .ParseArguments(args) + .WithParsed(GenerateMappers) + .WithParsed(GenerateModels) + .WithParsed(GenerateExtensions); + } + + private static string? GetSegments(string? ns, string? baseNs) + { + if (ns == null || string.IsNullOrEmpty(baseNs) || baseNs == ns) + return null; + return ns.StartsWith(baseNs + ".") ? ns.Substring(baseNs.Length + 1) : ns; + } + + private static string? CreateNamespace(string? ns, string? segment, string? typeNs) + { + if (ns == null) + return typeNs; + return segment == null ? ns : $"{ns}.{segment}"; + } + + private static string GetOutput(string baseOutput, string? segment, string typeName) + { + var fullBasePath = Path.GetFullPath(baseOutput); + return segment == null + ? Path.Combine(fullBasePath, typeName + ".g.cs") + : Path.Combine( + fullBasePath, + segment.Replace('.', Path.DirectorySeparatorChar), + typeName + ".g.cs" + ); + } + + private static void WriteFile(string code, string path) + { + var dir = Path.GetDirectoryName(path); + if (dir != null) + Directory.CreateDirectory(dir); + if (File.Exists(path)) + { + var old = File.ReadAllText(path); + if (old == code) + return; + } + File.WriteAllText(path, code); + } + + private static void GenerateMappers(MapperOptions opt) + { + // We want loaded assemblies that we're scanning to be isolated from our currently + // running assembly load context in order to avoid type/framework collisions between Mapster assemblies + // and their dependencies and the scanned assemblies and their dependencies + + // However, we also need *some* of those scanned assemblies and thus their types to resolve from our + // currently running AssemblyLoadContext.Default: The Mapster assembly basically. + + // This way when we compare attribute types (such as MapperAttribute) between our running assembly + // and the scanned assembly the two types with the same FullName can be considered equal because + // they both were resolved from AssemblyLoadContext.Default. + + // This isolated Assembly Load Context will be able to resolve the Mapster assembly, but + // the resolved Assembly will be the same one that is in AssemblyLoadContext.Default + // (the runtime assembly load context that our code refers to by default when referencing + // types) + var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( + assemblyPath: Path.GetFullPath(opt.Assembly), + deferToContext: AssemblyLoadContext.Default, + typeof(MapperAttribute).Assembly.GetName(), + typeof(IRegister).Assembly.GetName() + ); + var config = TypeAdapterConfig.GlobalSettings; + config.SelfContainedCodeGeneration = true; + config.Scan(assembly); + + var generatedAtrr = new List(); + + if (opt.CreateHelpers) + generatedAtrr.Add(new MapsterToolGeneratedMapperAttribute( + opt.HelpersNamespace ?? Path.GetFileNameWithoutExtension(opt.Assembly) + )); + + + foreach (var type in assembly.GetLoadableTypes()) + { + if (!type.IsInterface) + continue; + var attr = type.GetCustomAttribute(); + if (attr == null) + continue; + + Console.WriteLine($"Processing: {type.FullName}"); + + var segments = GetSegments(type.Namespace, opt.BaseNamespace); + var definitions = new TypeDefinitions + { + Implements = new[] { type }, + Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), + TypeName = attr.Name ?? GetImplName(GetCodeFriendlyTypeName(type)), + IsInternal = attr.IsInternal, + PrintFullTypeName = opt.PrintFullTypeName, + GeneratedAttributes = new(generatedAtrr) + }; + + bool? _isForceInternal = definitions.IsInternal ? true : null; + + var path = GetOutput(opt.Output, segments, definitions.TypeName); + if (opt.SkipExistingFiles && File.Exists(path)) + { + Console.WriteLine( + $"Skipped: {type.FullName}. Mapper {definitions.TypeName} already exists." + ); + continue; + } + + var translator = new ExpressionTranslator(definitions); + var interfaces = type.GetAllInterfaces(); + foreach (var @interface in interfaces) + { + foreach (var prop in @interface.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsGetterPublicOrInternal()) + ) + { + if (!prop.PropertyType.IsGenericType) + continue; + if (prop.PropertyType.GetGenericTypeDefinition() != typeof(Expression<>)) + continue; + var propArgs = prop.PropertyType.GetGenericArguments()[0]; + if (!propArgs.IsGenericType) + continue; + if (propArgs.GetGenericTypeDefinition() != typeof(Func<,>)) + continue; + var funcArgs = propArgs.GetGenericArguments(); + var tuple = new TypeTuple(funcArgs[0], funcArgs[1]); + var expr = config.CreateMapExpression(tuple, MapType.Projection); + translator.VisitLambdaForGenerateMappers( + expr, + ExpressionTranslator.LambdaType.PublicLambda, + @interface, + prop.Name, + _isForceInternal ?? (!prop.GetMethod?.IsPublic ?? false) + ); + } + } + + foreach (var @interface in interfaces) + { + foreach (var method in @interface.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(x => x.IsPublicOrInternal()) + ) + { + if (method.IsGenericMethod) + continue; + if (method.ReturnType == typeof(void)) + continue; + var methodArgs = method.GetParameters(); + if (methodArgs.Length < 1 || methodArgs.Length > 2) + continue; + var tuple = new TypeTuple(methodArgs[0].ParameterType, method.ReturnType); + var expr = config.CreateMapExpression( + tuple, + methodArgs.Length == 1 ? MapType.Map : MapType.MapToTarget + ); + translator.VisitLambdaForGenerateMappers( + expr, + ExpressionTranslator.LambdaType.PublicMethod, + @interface, + method.Name, + _isForceInternal ?? !method.IsPublic + ); + } + } + + var code = opt.GenerateNullableDirective + ? $"#nullable enable{Environment.NewLine}{translator}" + : translator.ToString(); + WriteFile(code, path); + } + + + foreach (var item in generatedAtrr) + { + WriteFile(item.Declaration, GetOutput(opt.Output, null, item.FileName)); + } + } + + private static string GetImplName(string name) + { + if (name.Length >= 2 && name[0] == 'I' && name[1] >= 'A' && name[1] <= 'Z') + return name.Substring(1); + return name + "Impl"; + } + + private static void GenerateModels(ModelOptions opt) + { + var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( + assemblyPath: Path.GetFullPath(opt.Assembly), + deferToContext: AssemblyLoadContext.Default, + typeof(MapperAttribute).Assembly.GetName(), + typeof(IRegister).Assembly.GetName() + ); + var codeGenConfig = new CodeGenerationConfig(); + codeGenConfig.Scan(assembly); + + var types = assembly.GetLoadableTypes().ToHashSet(); + foreach (var builder in codeGenConfig.AdaptAttributeBuilders) + { + foreach (var setting in builder.TypeSettings) + { + types.Add(setting.Key); + } + } + foreach (var type in types) + { + var builders = type.GetAdaptAttributeBuilders(codeGenConfig) + .Where( + it => + !string.IsNullOrEmpty(it.Attribute.Name) + && it.Attribute.Name != "[name]" + ) + .ToList(); + if (builders.Count == 0) + continue; + + Console.WriteLine($"Processing: {type.FullName}"); + foreach (var builder in builders) + { + CreateModel(opt, type, builder); + } + } + } + + private static byte? GetTypeNullableContext(Type type) + { + var nilCtxAttr = type.GetCustomAttributesData() + .FirstOrDefault(it => it.AttributeType.Name == "NullableContextAttribute"); + return + nilCtxAttr?.ConstructorArguments.Count == 1 + && nilCtxAttr.ConstructorArguments[0].Value is byte b + ? (byte?)b + : null; + } + + private static void CreateModel(ModelOptions opt, Type type, AdaptAttributeBuilder builder) + { + var segments = GetSegments(type.Namespace, opt.BaseNamespace); + var attr = builder.Attribute; + var definitions = new TypeDefinitions + { + Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), + TypeName = attr.Name!.Replace("[name]", type.Name), + PrintFullTypeName = opt.PrintFullTypeName, + IsRecordType = opt.IsRecordType, + NullableContext = GetTypeNullableContext(type), + }; + + var path = GetOutput(opt.Output, segments, definitions.TypeName); + if (opt.SkipExistingFiles && File.Exists(path)) + { + Console.WriteLine( + $"Skipped: {type.FullName}. Model {definitions.TypeName} already exists." + ); + return; + } + + var translator = new ExpressionTranslator(definitions); + var isAdaptTo = attr is AdaptToAttribute; + var isTwoWays = attr is AdaptTwoWaysAttribute; + var side = isAdaptTo ? MemberSide.Source : MemberSide.Destination; + var properties = type.GetFieldsAndProperties() + .Where( + it => + !it.SafeGetCustomAttributes() + .OfType() + .Any(it2 => isTwoWays || it2.Side == null || it2.Side == side) + ); + + if (attr.IgnoreAttributes != null) + { + properties = properties.Where( + it => + !it.SafeGetCustomAttributes() + .Select(it2 => it2.GetType()) + .Intersect(attr.IgnoreAttributes) + .Any() + ); + } + + if (attr.IgnoreNoAttributes != null) + { + properties = properties.Where( + it => + it.SafeGetCustomAttributes() + .Select(it2 => it2.GetType()) + .Intersect(attr.IgnoreNoAttributes) + .Any() + ); + } + + if (attr.IgnoreNamespaces != null) + { + foreach (var ns in attr.IgnoreNamespaces) + { + properties = properties.Where( + it => getPropType(it).Namespace?.StartsWith(ns) != true + ); + } + } + + var propSettings = builder.TypeSettings.GetValueOrDefault(type); + var isReadOnly = isAdaptTo && attr.MapToConstructor; + var isNullable = !isAdaptTo && attr.IgnoreNullValues; + foreach (var member in properties) + { + var setting = propSettings?.GetValueOrDefault(member.Name); + if (setting?.Ignore == true) + continue; + + var adaptMember = member.GetCustomAttribute(); + if (!isTwoWays && adaptMember?.Side != null && adaptMember.Side != side) + adaptMember = null; + var propType = + setting?.MapFunc?.ReturnType + ?? setting?.TargetPropertyType + ?? GetPropertyType( + member, + getPropType(member), + attr.GetType(), + opt.Namespace, + builder + ); + var nilAttr = member + .GetCustomAttributesData() + .FirstOrDefault(it => it.AttributeType.Name == "NullableAttribute"); + var nilAttrArg = + nilAttr?.ConstructorArguments.Count == 1 + ? nilAttr.ConstructorArguments[0].Value + : null; + translator.Properties.Add( + new PropertyDefinitions + { + Name = setting?.TargetPropertyName ?? adaptMember?.Name ?? member.Name, + Type = isNullable ? propType.MakeNullable() : propType, + IsReadOnly = isReadOnly, + NullableContext = nilAttrArg is byte b ? (byte?)b : null, + Nullable = nilAttrArg is byte[] bytes ? bytes : null, + } + ); + } + + var code = opt.GenerateNullableDirective + ? $"#nullable enable{Environment.NewLine}{translator}" + : translator.ToString(); + WriteFile(code, path); + + static Type getPropType(MemberInfo mem) + { + return mem is PropertyInfo p ? p.PropertyType : ((FieldInfo)mem).FieldType; + } + } + + private static readonly Dictionary _mockTypes = + new Dictionary(); + + private static Type GetPropertyType( + MemberInfo member, + Type propType, + Type attrType, + string? ns, + AdaptAttributeBuilder builder + ) + { + var navAttr = member + .SafeGetCustomAttributes() + .OfType() + .FirstOrDefault(it => it.ForAttributes?.Contains(attrType) != false); + if (navAttr != null) + return navAttr.Type; + + if ( + propType.IsCollection() + && propType.IsCollectionCompatible() + && propType.IsGenericType + && propType.GetGenericArguments().Length == 1 + ) + { + var elementType = propType.GetGenericArguments()[0]; + var newType = GetPropertyType(member, elementType, attrType, ns, builder); + if (elementType == newType) + return propType; + var generic = propType.GetGenericTypeDefinition(); + return generic.MakeGenericType(newType); + } + + var alterType = builder.AlterTypes + .Select(fn => fn(propType)) + .FirstOrDefault(it => it != null); + if (alterType != null) + return alterType; + + var propTypeAttrs = propType.SafeGetCustomAttributes(); + navAttr = propTypeAttrs + .OfType() + .FirstOrDefault(it => it.ForAttributes?.Contains(attrType) != false); + if (navAttr != null) + return navAttr.Type; + + var adaptAttr = builder.TypeSettings.ContainsKey(propType) + ? (BaseAdaptAttribute?)builder.Attribute + : propTypeAttrs + .OfType() + .FirstOrDefault(it => it.GetType() == attrType); + if (adaptAttr == null) + return propType; + if (adaptAttr.Type != null) + return adaptAttr.Type; + + var name = adaptAttr.Name!.Replace("[name]", propType.Name); + if (!_mockTypes.TryGetValue(name, out var mockType)) + { + mockType = new MockType(ns ?? propType.Namespace!, name, propType.Assembly); + _mockTypes[name] = mockType; + } + return mockType; + } + + private static Type? GetFromType(Type type, BaseAdaptAttribute attr, HashSet types) + { + if (!(attr is AdaptFromAttribute) && !(attr is AdaptTwoWaysAttribute)) + return null; + + var fromType = attr.Type; + if (fromType == null && attr.Name != null) + { + var name = attr.Name.Replace("[name]", type.Name); + fromType = types.FirstOrDefault(it => it.Name == name); + } + + return fromType; + } + + private static Type? GetToType(Type type, BaseAdaptAttribute attr, HashSet types) + { + if (!(attr is AdaptToAttribute)) + return null; + + var toType = attr.Type; + if (toType == null && attr.Name != null) + { + var name = attr.Name.Replace("[name]", type.Name); + toType = types.FirstOrDefault(it => it.Name == name); + } + + return toType; + } + + private static void ApplySettings( + TypeAdapterSetter setter, + BaseAdaptAttribute attr, + Dictionary settings + ) + { + setter.ApplyAdaptAttribute(attr); + foreach (var (name, setting) in settings) + { + if (setting.MapFunc != null) + { + setter.Settings.Resolvers.Add( + new InvokerModel + { + DestinationMemberName = setting.TargetPropertyName ?? name, + SourceMemberName = name, + Invoker = setting.MapFunc, + } + ); + } + else if (setting.TargetPropertyName != null) + { + setter.Map(setting.TargetPropertyName, name); + } + } + } + + private static void GenerateExtensions(ExtensionOptions opt) + { + var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( + assemblyPath: Path.GetFullPath(opt.Assembly), + deferToContext: AssemblyLoadContext.Default, + typeof(MapperAttribute).Assembly.GetName(), + typeof(IRegister).Assembly.GetName() + ); + var config = TypeAdapterConfig.GlobalSettings; + config.SelfContainedCodeGeneration = true; + config.Scan(assembly); + var codeGenConfig = new CodeGenerationConfig(); + codeGenConfig.Scan(assembly); + + var assemblies = new HashSet { assembly }; + foreach (var builder in codeGenConfig.AdaptAttributeBuilders) + { + foreach (var setting in builder.TypeSettings) + { + assemblies.Add(setting.Key.Assembly); + } + } + var types = assemblies.SelectMany(it => it.GetLoadableTypes()).ToHashSet(); + + // assemblies defines open generic only, so we have to add specialised types used in mappings + foreach (var (key, _) in config.RuleMap) + types.Add(key.Source); + var configDict = new Dictionary(); + foreach (var builder in codeGenConfig.AdaptAttributeBuilders) + { + var attr = builder.Attribute; + var cloned = config.Clone(); + foreach (var (type, settings) in builder.TypeSettings) + { + var fromType = GetFromType(type, attr, types); + if (fromType != null) + ApplySettings(cloned.ForType(fromType, type), attr, settings); + + var toType = GetToType(type, attr, types); + if (toType != null) + ApplySettings(cloned.ForType(type, toType), attr, settings); + } + + configDict[attr] = cloned; + } + + foreach (var type in types) + { + var mapperAttr = type.GetGenerateMapperAttributes(codeGenConfig).FirstOrDefault(); + var ruleMaps = config.RuleMap + .Where( + it => it.Key.Source == type && it.Value.Settings.GenerateMapper is MapType + ) + .ToList(); + if (mapperAttr == null && ruleMaps.Count == 0) + continue; + + mapperAttr ??= new GenerateMapperAttribute(); + var set = mapperAttr.ForAttributes?.ToHashSet(); + var builders = type.GetAdaptAttributeBuilders(codeGenConfig) + .Where(it => set?.Contains(it.GetType()) != false) + .ToList(); + if (builders.Count == 0 && ruleMaps.Count == 0) + continue; + + Console.WriteLine($"Processing: {type.FullName}"); + + var segments = GetSegments(type.Namespace, opt.BaseNamespace); + var definitions = new TypeDefinitions + { + IsStatic = true, + Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), + TypeName = mapperAttr.Name.Replace("[name]", GetCodeFriendlyTypeName(type)), + IsInternal = mapperAttr.IsInternal, + PrintFullTypeName = opt.PrintFullTypeName, + }; + + var path = GetOutput(opt.Output, segments, definitions.TypeName); + if (opt.SkipExistingFiles && File.Exists(path)) + { + Console.WriteLine( + $"Skipped: {type.FullName}. Extension class {definitions.TypeName} already exists." + ); + continue; + } + + var translator = new ExpressionTranslator(definitions); + + foreach (var builder in builders) + { + var attr = builder.Attribute; + var cloned = configDict.GetValueOrDefault(attr) ?? config; + var fromType = GetFromType(type, attr, types); + if (fromType != null) + { + var tuple = new TypeTuple(fromType, type); + var mapType = + attr.MapType == 0 ? MapType.Map | MapType.MapToTarget : attr.MapType; + GenerateExtensionMethods( + mapType, + cloned, + tuple, + translator, + type, + mapperAttr.IsHelperClass + ); + } + + var toType = GetToType(type, attr, types); + if (toType != null && (!(attr is AdaptTwoWaysAttribute) || type != toType)) + { + var tuple = new TypeTuple(type, toType); + var mapType = + attr.MapType == 0 ? MapType.Map | MapType.MapToTarget : attr.MapType; + GenerateExtensionMethods( + mapType, + cloned, + tuple, + translator, + type, + mapperAttr.IsHelperClass + ); + } + } + + foreach (var (tuple, rule) in ruleMaps) + { + var mapType = (MapType)rule.Settings.GenerateMapper!; + GenerateExtensionMethods( + mapType, + config, + tuple, + translator, + type, + mapperAttr.IsHelperClass + ); + } + + var code = opt.GenerateNullableDirective + ? $"#nullable enable{Environment.NewLine}{translator}" + : translator.ToString(); + WriteFile(code, path); + } + } + + private static void GenerateExtensionMethods( + MapType mapType, + TypeAdapterConfig config, + TypeTuple tuple, + ExpressionTranslator translator, + Type entityType, + bool isHelperClass + ) + { + //add type name to prevent duplication + translator.Translate(entityType); + var destName = GetCodeFriendlyTypeName(tuple.Destination); + + var name = + tuple.Destination.Name == entityType.Name + ? destName + : destName.Replace(entityType.Name, ""); + if ((mapType & MapType.Map) > 0) + { + var expr = config.CreateMapExpression(tuple, MapType.Map); + translator.VisitLambda( + expr, + isHelperClass + ? ExpressionTranslator.LambdaType.PublicMethod + : ExpressionTranslator.LambdaType.ExtensionMethod, + "AdaptTo" + name + ); + } + + if ((mapType & MapType.MapToTarget) > 0) + { + var expr2 = config.CreateMapExpression(tuple, MapType.MapToTarget); + translator.VisitLambda( + expr2, + isHelperClass + ? ExpressionTranslator.LambdaType.PublicMethod + : ExpressionTranslator.LambdaType.ExtensionMethod, + "AdaptTo" + ); + } + + if ((mapType & MapType.Projection) > 0) + { + var proj = config.CreateMapExpression(tuple, MapType.Projection); + translator.VisitLambda( + proj, + ExpressionTranslator.LambdaType.PublicLambda, + "ProjectTo" + name + ); + } + } + + private static string GetCodeFriendlyTypeName(Type type) => + GetCodeFriendlyTypeName(new StringBuilder(), type).ToString(); + + private static StringBuilder GetCodeFriendlyTypeName(StringBuilder sb, Type type) + { + foreach (var subType in type.GenericTypeArguments) + { + GetCodeFriendlyTypeName(sb, subType); + } + + if (type.IsArray) + { + GetCodeFriendlyTypeName(sb, type.GetElementType()!); + sb.Append("Array"); + return sb; + } + + var name = type.Name; + var i = name.IndexOf('`'); + if (i > 0) + name = name.Remove(i); + name = name switch + { + "SByte" => "Sbyte", + "Int16" => "Short", + "UInt16" => "Ushort", + "Int32" => "Int", + "UInt32" => "Uint", + "Int64" => "Long", + "UInt64" => "Ulong", + "Single" => "Float", + "Boolean" => "Bool", + _ => name, + }; + + if (!string.IsNullOrEmpty(name)) + sb.Append(name); + return sb; + } + } +} From 5f05185fa211464890a6979339b49da9bc73c655 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 09:22:14 +0500 Subject: [PATCH 25/32] feat(test): Added Mapster.Tool.Tests as Friend assembly for Mapster.Tool refactoring Mapster.Tool to simplify testing --- src/Mapster.Tool/Generators.cs | 46 ++- src/Mapster.Tool/Program.cs | 724 +-------------------------------- 2 files changed, 34 insertions(+), 736 deletions(-) diff --git a/src/Mapster.Tool/Generators.cs b/src/Mapster.Tool/Generators.cs index e93ce4d4..64522eff 100644 --- a/src/Mapster.Tool/Generators.cs +++ b/src/Mapster.Tool/Generators.cs @@ -1,5 +1,4 @@ -using CommandLine; -using ExpressionDebugger; +using ExpressionDebugger; using ExpressionDebugger.Helpers; using ExpressionDebugger.Helpers.GeneratedAttributes; using Mapster.Models; @@ -15,17 +14,9 @@ namespace Mapster.Tool { - class Program + internal static class Generators { - static void Main(string[] args) - { - Parser.Default - .ParseArguments(args) - .WithParsed(GenerateMappers) - .WithParsed(GenerateModels) - .WithParsed(GenerateExtensions); - } - + private static string? GetSegments(string? ns, string? baseNs) { if (ns == null || string.IsNullOrEmpty(baseNs) || baseNs == ns) @@ -66,7 +57,7 @@ private static void WriteFile(string code, string path) File.WriteAllText(path, code); } - private static void GenerateMappers(MapperOptions opt) + internal static void GenerateMappers(MapperOptions opt, List? DebugMappers = null) { // We want loaded assemblies that we're scanning to be isolated from our currently // running assembly load context in order to avoid type/framework collisions between Mapster assemblies @@ -194,7 +185,12 @@ private static void GenerateMappers(MapperOptions opt) var code = opt.GenerateNullableDirective ? $"#nullable enable{Environment.NewLine}{translator}" : translator.ToString(); - WriteFile(code, path); + + // Debug only mode - create mapper code, not print to file + if (DebugMappers != null) + DebugMappers.Add(code); + else + WriteFile(code, path); } @@ -211,7 +207,7 @@ private static string GetImplName(string name) return name + "Impl"; } - private static void GenerateModels(ModelOptions opt) + internal static void GenerateModels(ModelOptions opt, List? DebugModels = null) { var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( assemblyPath: Path.GetFullPath(opt.Assembly), @@ -245,7 +241,7 @@ private static void GenerateModels(ModelOptions opt) Console.WriteLine($"Processing: {type.FullName}"); foreach (var builder in builders) { - CreateModel(opt, type, builder); + CreateModel(opt, type, builder, DebugModels); } } } @@ -261,7 +257,7 @@ private static void GenerateModels(ModelOptions opt) : null; } - private static void CreateModel(ModelOptions opt, Type type, AdaptAttributeBuilder builder) + private static void CreateModel(ModelOptions opt, Type type, AdaptAttributeBuilder builder, List? DebugModels) { var segments = GetSegments(type.Namespace, opt.BaseNamespace); var attr = builder.Attribute; @@ -371,7 +367,12 @@ private static void CreateModel(ModelOptions opt, Type type, AdaptAttributeBuild var code = opt.GenerateNullableDirective ? $"#nullable enable{Environment.NewLine}{translator}" : translator.ToString(); - WriteFile(code, path); + + // Debug only mode - create model code, not print to file + if (DebugModels != null) + DebugModels.Add(code); + else + WriteFile(code, path); static Type getPropType(MemberInfo mem) { @@ -501,7 +502,7 @@ Dictionary settings } } - private static void GenerateExtensions(ExtensionOptions opt) + internal static void GenerateExtensions(ExtensionOptions opt, List? DebugExtentions = null) { var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( assemblyPath: Path.GetFullPath(opt.Assembly), @@ -642,7 +643,12 @@ private static void GenerateExtensions(ExtensionOptions opt) var code = opt.GenerateNullableDirective ? $"#nullable enable{Environment.NewLine}{translator}" : translator.ToString(); - WriteFile(code, path); + + // Debug only mode - create ExtensionMethods code, not print to file + if (DebugExtentions != null) + DebugExtentions.Add(code); + else + WriteFile(code, path); } } diff --git a/src/Mapster.Tool/Program.cs b/src/Mapster.Tool/Program.cs index e93ce4d4..7290e4b1 100644 --- a/src/Mapster.Tool/Program.cs +++ b/src/Mapster.Tool/Program.cs @@ -1,18 +1,7 @@ using CommandLine; -using ExpressionDebugger; -using ExpressionDebugger.Helpers; -using ExpressionDebugger.Helpers.GeneratedAttributes; -using Mapster.Models; -using Mapster.Utils; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Runtime.Loader; -using System.Text; +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Mapster.Tool.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd523e79e4decc052a3501363d71ecc123b9ce4bd5a8c949e81bc482d8b6822366ed6aead5ebace01aae3ade49e116fde094af03c34cdbc2ebcb89346ca510fac6246b240b71968ab7f9a24de44d680dc93307f9e8a2b00bec7c523db9696679b56725d622cfb01f4eb2604333a0a0e9f580cd6f5c3d5034b3e66f52d818e9a5")] namespace Mapster.Tool { class Program @@ -26,716 +15,19 @@ static void Main(string[] args) .WithParsed(GenerateExtensions); } - private static string? GetSegments(string? ns, string? baseNs) + private static void GenerateExtensions(ExtensionOptions options) { - if (ns == null || string.IsNullOrEmpty(baseNs) || baseNs == ns) - return null; - return ns.StartsWith(baseNs + ".") ? ns.Substring(baseNs.Length + 1) : ns; + Generators.GenerateExtensions(options); } - private static string? CreateNamespace(string? ns, string? segment, string? typeNs) + private static void GenerateModels(ModelOptions options) { - if (ns == null) - return typeNs; - return segment == null ? ns : $"{ns}.{segment}"; + Generators.GenerateModels(options); } - private static string GetOutput(string baseOutput, string? segment, string typeName) + private static void GenerateMappers(MapperOptions options) { - var fullBasePath = Path.GetFullPath(baseOutput); - return segment == null - ? Path.Combine(fullBasePath, typeName + ".g.cs") - : Path.Combine( - fullBasePath, - segment.Replace('.', Path.DirectorySeparatorChar), - typeName + ".g.cs" - ); - } - - private static void WriteFile(string code, string path) - { - var dir = Path.GetDirectoryName(path); - if (dir != null) - Directory.CreateDirectory(dir); - if (File.Exists(path)) - { - var old = File.ReadAllText(path); - if (old == code) - return; - } - File.WriteAllText(path, code); - } - - private static void GenerateMappers(MapperOptions opt) - { - // We want loaded assemblies that we're scanning to be isolated from our currently - // running assembly load context in order to avoid type/framework collisions between Mapster assemblies - // and their dependencies and the scanned assemblies and their dependencies - - // However, we also need *some* of those scanned assemblies and thus their types to resolve from our - // currently running AssemblyLoadContext.Default: The Mapster assembly basically. - - // This way when we compare attribute types (such as MapperAttribute) between our running assembly - // and the scanned assembly the two types with the same FullName can be considered equal because - // they both were resolved from AssemblyLoadContext.Default. - - // This isolated Assembly Load Context will be able to resolve the Mapster assembly, but - // the resolved Assembly will be the same one that is in AssemblyLoadContext.Default - // (the runtime assembly load context that our code refers to by default when referencing - // types) - var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( - assemblyPath: Path.GetFullPath(opt.Assembly), - deferToContext: AssemblyLoadContext.Default, - typeof(MapperAttribute).Assembly.GetName(), - typeof(IRegister).Assembly.GetName() - ); - var config = TypeAdapterConfig.GlobalSettings; - config.SelfContainedCodeGeneration = true; - config.Scan(assembly); - - var generatedAtrr = new List(); - - if (opt.CreateHelpers) - generatedAtrr.Add(new MapsterToolGeneratedMapperAttribute( - opt.HelpersNamespace ?? Path.GetFileNameWithoutExtension(opt.Assembly) - )); - - - foreach (var type in assembly.GetLoadableTypes()) - { - if (!type.IsInterface) - continue; - var attr = type.GetCustomAttribute(); - if (attr == null) - continue; - - Console.WriteLine($"Processing: {type.FullName}"); - - var segments = GetSegments(type.Namespace, opt.BaseNamespace); - var definitions = new TypeDefinitions - { - Implements = new[] { type }, - Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), - TypeName = attr.Name ?? GetImplName(GetCodeFriendlyTypeName(type)), - IsInternal = attr.IsInternal, - PrintFullTypeName = opt.PrintFullTypeName, - GeneratedAttributes = new(generatedAtrr) - }; - - bool? _isForceInternal = definitions.IsInternal ? true : null; - - var path = GetOutput(opt.Output, segments, definitions.TypeName); - if (opt.SkipExistingFiles && File.Exists(path)) - { - Console.WriteLine( - $"Skipped: {type.FullName}. Mapper {definitions.TypeName} already exists." - ); - continue; - } - - var translator = new ExpressionTranslator(definitions); - var interfaces = type.GetAllInterfaces(); - foreach (var @interface in interfaces) - { - foreach (var prop in @interface.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(x => x.IsGetterPublicOrInternal()) - ) - { - if (!prop.PropertyType.IsGenericType) - continue; - if (prop.PropertyType.GetGenericTypeDefinition() != typeof(Expression<>)) - continue; - var propArgs = prop.PropertyType.GetGenericArguments()[0]; - if (!propArgs.IsGenericType) - continue; - if (propArgs.GetGenericTypeDefinition() != typeof(Func<,>)) - continue; - var funcArgs = propArgs.GetGenericArguments(); - var tuple = new TypeTuple(funcArgs[0], funcArgs[1]); - var expr = config.CreateMapExpression(tuple, MapType.Projection); - translator.VisitLambdaForGenerateMappers( - expr, - ExpressionTranslator.LambdaType.PublicLambda, - @interface, - prop.Name, - _isForceInternal ?? (!prop.GetMethod?.IsPublic ?? false) - ); - } - } - - foreach (var @interface in interfaces) - { - foreach (var method in @interface.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(x => x.IsPublicOrInternal()) - ) - { - if (method.IsGenericMethod) - continue; - if (method.ReturnType == typeof(void)) - continue; - var methodArgs = method.GetParameters(); - if (methodArgs.Length < 1 || methodArgs.Length > 2) - continue; - var tuple = new TypeTuple(methodArgs[0].ParameterType, method.ReturnType); - var expr = config.CreateMapExpression( - tuple, - methodArgs.Length == 1 ? MapType.Map : MapType.MapToTarget - ); - translator.VisitLambdaForGenerateMappers( - expr, - ExpressionTranslator.LambdaType.PublicMethod, - @interface, - method.Name, - _isForceInternal ?? !method.IsPublic - ); - } - } - - var code = opt.GenerateNullableDirective - ? $"#nullable enable{Environment.NewLine}{translator}" - : translator.ToString(); - WriteFile(code, path); - } - - - foreach (var item in generatedAtrr) - { - WriteFile(item.Declaration, GetOutput(opt.Output, null, item.FileName)); - } - } - - private static string GetImplName(string name) - { - if (name.Length >= 2 && name[0] == 'I' && name[1] >= 'A' && name[1] <= 'Z') - return name.Substring(1); - return name + "Impl"; - } - - private static void GenerateModels(ModelOptions opt) - { - var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( - assemblyPath: Path.GetFullPath(opt.Assembly), - deferToContext: AssemblyLoadContext.Default, - typeof(MapperAttribute).Assembly.GetName(), - typeof(IRegister).Assembly.GetName() - ); - var codeGenConfig = new CodeGenerationConfig(); - codeGenConfig.Scan(assembly); - - var types = assembly.GetLoadableTypes().ToHashSet(); - foreach (var builder in codeGenConfig.AdaptAttributeBuilders) - { - foreach (var setting in builder.TypeSettings) - { - types.Add(setting.Key); - } - } - foreach (var type in types) - { - var builders = type.GetAdaptAttributeBuilders(codeGenConfig) - .Where( - it => - !string.IsNullOrEmpty(it.Attribute.Name) - && it.Attribute.Name != "[name]" - ) - .ToList(); - if (builders.Count == 0) - continue; - - Console.WriteLine($"Processing: {type.FullName}"); - foreach (var builder in builders) - { - CreateModel(opt, type, builder); - } - } - } - - private static byte? GetTypeNullableContext(Type type) - { - var nilCtxAttr = type.GetCustomAttributesData() - .FirstOrDefault(it => it.AttributeType.Name == "NullableContextAttribute"); - return - nilCtxAttr?.ConstructorArguments.Count == 1 - && nilCtxAttr.ConstructorArguments[0].Value is byte b - ? (byte?)b - : null; - } - - private static void CreateModel(ModelOptions opt, Type type, AdaptAttributeBuilder builder) - { - var segments = GetSegments(type.Namespace, opt.BaseNamespace); - var attr = builder.Attribute; - var definitions = new TypeDefinitions - { - Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), - TypeName = attr.Name!.Replace("[name]", type.Name), - PrintFullTypeName = opt.PrintFullTypeName, - IsRecordType = opt.IsRecordType, - NullableContext = GetTypeNullableContext(type), - }; - - var path = GetOutput(opt.Output, segments, definitions.TypeName); - if (opt.SkipExistingFiles && File.Exists(path)) - { - Console.WriteLine( - $"Skipped: {type.FullName}. Model {definitions.TypeName} already exists." - ); - return; - } - - var translator = new ExpressionTranslator(definitions); - var isAdaptTo = attr is AdaptToAttribute; - var isTwoWays = attr is AdaptTwoWaysAttribute; - var side = isAdaptTo ? MemberSide.Source : MemberSide.Destination; - var properties = type.GetFieldsAndProperties() - .Where( - it => - !it.SafeGetCustomAttributes() - .OfType() - .Any(it2 => isTwoWays || it2.Side == null || it2.Side == side) - ); - - if (attr.IgnoreAttributes != null) - { - properties = properties.Where( - it => - !it.SafeGetCustomAttributes() - .Select(it2 => it2.GetType()) - .Intersect(attr.IgnoreAttributes) - .Any() - ); - } - - if (attr.IgnoreNoAttributes != null) - { - properties = properties.Where( - it => - it.SafeGetCustomAttributes() - .Select(it2 => it2.GetType()) - .Intersect(attr.IgnoreNoAttributes) - .Any() - ); - } - - if (attr.IgnoreNamespaces != null) - { - foreach (var ns in attr.IgnoreNamespaces) - { - properties = properties.Where( - it => getPropType(it).Namespace?.StartsWith(ns) != true - ); - } - } - - var propSettings = builder.TypeSettings.GetValueOrDefault(type); - var isReadOnly = isAdaptTo && attr.MapToConstructor; - var isNullable = !isAdaptTo && attr.IgnoreNullValues; - foreach (var member in properties) - { - var setting = propSettings?.GetValueOrDefault(member.Name); - if (setting?.Ignore == true) - continue; - - var adaptMember = member.GetCustomAttribute(); - if (!isTwoWays && adaptMember?.Side != null && adaptMember.Side != side) - adaptMember = null; - var propType = - setting?.MapFunc?.ReturnType - ?? setting?.TargetPropertyType - ?? GetPropertyType( - member, - getPropType(member), - attr.GetType(), - opt.Namespace, - builder - ); - var nilAttr = member - .GetCustomAttributesData() - .FirstOrDefault(it => it.AttributeType.Name == "NullableAttribute"); - var nilAttrArg = - nilAttr?.ConstructorArguments.Count == 1 - ? nilAttr.ConstructorArguments[0].Value - : null; - translator.Properties.Add( - new PropertyDefinitions - { - Name = setting?.TargetPropertyName ?? adaptMember?.Name ?? member.Name, - Type = isNullable ? propType.MakeNullable() : propType, - IsReadOnly = isReadOnly, - NullableContext = nilAttrArg is byte b ? (byte?)b : null, - Nullable = nilAttrArg is byte[] bytes ? bytes : null, - } - ); - } - - var code = opt.GenerateNullableDirective - ? $"#nullable enable{Environment.NewLine}{translator}" - : translator.ToString(); - WriteFile(code, path); - - static Type getPropType(MemberInfo mem) - { - return mem is PropertyInfo p ? p.PropertyType : ((FieldInfo)mem).FieldType; - } - } - - private static readonly Dictionary _mockTypes = - new Dictionary(); - - private static Type GetPropertyType( - MemberInfo member, - Type propType, - Type attrType, - string? ns, - AdaptAttributeBuilder builder - ) - { - var navAttr = member - .SafeGetCustomAttributes() - .OfType() - .FirstOrDefault(it => it.ForAttributes?.Contains(attrType) != false); - if (navAttr != null) - return navAttr.Type; - - if ( - propType.IsCollection() - && propType.IsCollectionCompatible() - && propType.IsGenericType - && propType.GetGenericArguments().Length == 1 - ) - { - var elementType = propType.GetGenericArguments()[0]; - var newType = GetPropertyType(member, elementType, attrType, ns, builder); - if (elementType == newType) - return propType; - var generic = propType.GetGenericTypeDefinition(); - return generic.MakeGenericType(newType); - } - - var alterType = builder.AlterTypes - .Select(fn => fn(propType)) - .FirstOrDefault(it => it != null); - if (alterType != null) - return alterType; - - var propTypeAttrs = propType.SafeGetCustomAttributes(); - navAttr = propTypeAttrs - .OfType() - .FirstOrDefault(it => it.ForAttributes?.Contains(attrType) != false); - if (navAttr != null) - return navAttr.Type; - - var adaptAttr = builder.TypeSettings.ContainsKey(propType) - ? (BaseAdaptAttribute?)builder.Attribute - : propTypeAttrs - .OfType() - .FirstOrDefault(it => it.GetType() == attrType); - if (adaptAttr == null) - return propType; - if (adaptAttr.Type != null) - return adaptAttr.Type; - - var name = adaptAttr.Name!.Replace("[name]", propType.Name); - if (!_mockTypes.TryGetValue(name, out var mockType)) - { - mockType = new MockType(ns ?? propType.Namespace!, name, propType.Assembly); - _mockTypes[name] = mockType; - } - return mockType; - } - - private static Type? GetFromType(Type type, BaseAdaptAttribute attr, HashSet types) - { - if (!(attr is AdaptFromAttribute) && !(attr is AdaptTwoWaysAttribute)) - return null; - - var fromType = attr.Type; - if (fromType == null && attr.Name != null) - { - var name = attr.Name.Replace("[name]", type.Name); - fromType = types.FirstOrDefault(it => it.Name == name); - } - - return fromType; - } - - private static Type? GetToType(Type type, BaseAdaptAttribute attr, HashSet types) - { - if (!(attr is AdaptToAttribute)) - return null; - - var toType = attr.Type; - if (toType == null && attr.Name != null) - { - var name = attr.Name.Replace("[name]", type.Name); - toType = types.FirstOrDefault(it => it.Name == name); - } - - return toType; - } - - private static void ApplySettings( - TypeAdapterSetter setter, - BaseAdaptAttribute attr, - Dictionary settings - ) - { - setter.ApplyAdaptAttribute(attr); - foreach (var (name, setting) in settings) - { - if (setting.MapFunc != null) - { - setter.Settings.Resolvers.Add( - new InvokerModel - { - DestinationMemberName = setting.TargetPropertyName ?? name, - SourceMemberName = name, - Invoker = setting.MapFunc, - } - ); - } - else if (setting.TargetPropertyName != null) - { - setter.Map(setting.TargetPropertyName, name); - } - } - } - - private static void GenerateExtensions(ExtensionOptions opt) - { - var assembly = DeferredDependencyAssemblyLoadContext.LoadAssemblyFrom( - assemblyPath: Path.GetFullPath(opt.Assembly), - deferToContext: AssemblyLoadContext.Default, - typeof(MapperAttribute).Assembly.GetName(), - typeof(IRegister).Assembly.GetName() - ); - var config = TypeAdapterConfig.GlobalSettings; - config.SelfContainedCodeGeneration = true; - config.Scan(assembly); - var codeGenConfig = new CodeGenerationConfig(); - codeGenConfig.Scan(assembly); - - var assemblies = new HashSet { assembly }; - foreach (var builder in codeGenConfig.AdaptAttributeBuilders) - { - foreach (var setting in builder.TypeSettings) - { - assemblies.Add(setting.Key.Assembly); - } - } - var types = assemblies.SelectMany(it => it.GetLoadableTypes()).ToHashSet(); - - // assemblies defines open generic only, so we have to add specialised types used in mappings - foreach (var (key, _) in config.RuleMap) - types.Add(key.Source); - var configDict = new Dictionary(); - foreach (var builder in codeGenConfig.AdaptAttributeBuilders) - { - var attr = builder.Attribute; - var cloned = config.Clone(); - foreach (var (type, settings) in builder.TypeSettings) - { - var fromType = GetFromType(type, attr, types); - if (fromType != null) - ApplySettings(cloned.ForType(fromType, type), attr, settings); - - var toType = GetToType(type, attr, types); - if (toType != null) - ApplySettings(cloned.ForType(type, toType), attr, settings); - } - - configDict[attr] = cloned; - } - - foreach (var type in types) - { - var mapperAttr = type.GetGenerateMapperAttributes(codeGenConfig).FirstOrDefault(); - var ruleMaps = config.RuleMap - .Where( - it => it.Key.Source == type && it.Value.Settings.GenerateMapper is MapType - ) - .ToList(); - if (mapperAttr == null && ruleMaps.Count == 0) - continue; - - mapperAttr ??= new GenerateMapperAttribute(); - var set = mapperAttr.ForAttributes?.ToHashSet(); - var builders = type.GetAdaptAttributeBuilders(codeGenConfig) - .Where(it => set?.Contains(it.GetType()) != false) - .ToList(); - if (builders.Count == 0 && ruleMaps.Count == 0) - continue; - - Console.WriteLine($"Processing: {type.FullName}"); - - var segments = GetSegments(type.Namespace, opt.BaseNamespace); - var definitions = new TypeDefinitions - { - IsStatic = true, - Namespace = CreateNamespace(opt.Namespace, segments, type.Namespace), - TypeName = mapperAttr.Name.Replace("[name]", GetCodeFriendlyTypeName(type)), - IsInternal = mapperAttr.IsInternal, - PrintFullTypeName = opt.PrintFullTypeName, - }; - - var path = GetOutput(opt.Output, segments, definitions.TypeName); - if (opt.SkipExistingFiles && File.Exists(path)) - { - Console.WriteLine( - $"Skipped: {type.FullName}. Extension class {definitions.TypeName} already exists." - ); - continue; - } - - var translator = new ExpressionTranslator(definitions); - - foreach (var builder in builders) - { - var attr = builder.Attribute; - var cloned = configDict.GetValueOrDefault(attr) ?? config; - var fromType = GetFromType(type, attr, types); - if (fromType != null) - { - var tuple = new TypeTuple(fromType, type); - var mapType = - attr.MapType == 0 ? MapType.Map | MapType.MapToTarget : attr.MapType; - GenerateExtensionMethods( - mapType, - cloned, - tuple, - translator, - type, - mapperAttr.IsHelperClass - ); - } - - var toType = GetToType(type, attr, types); - if (toType != null && (!(attr is AdaptTwoWaysAttribute) || type != toType)) - { - var tuple = new TypeTuple(type, toType); - var mapType = - attr.MapType == 0 ? MapType.Map | MapType.MapToTarget : attr.MapType; - GenerateExtensionMethods( - mapType, - cloned, - tuple, - translator, - type, - mapperAttr.IsHelperClass - ); - } - } - - foreach (var (tuple, rule) in ruleMaps) - { - var mapType = (MapType)rule.Settings.GenerateMapper!; - GenerateExtensionMethods( - mapType, - config, - tuple, - translator, - type, - mapperAttr.IsHelperClass - ); - } - - var code = opt.GenerateNullableDirective - ? $"#nullable enable{Environment.NewLine}{translator}" - : translator.ToString(); - WriteFile(code, path); - } - } - - private static void GenerateExtensionMethods( - MapType mapType, - TypeAdapterConfig config, - TypeTuple tuple, - ExpressionTranslator translator, - Type entityType, - bool isHelperClass - ) - { - //add type name to prevent duplication - translator.Translate(entityType); - var destName = GetCodeFriendlyTypeName(tuple.Destination); - - var name = - tuple.Destination.Name == entityType.Name - ? destName - : destName.Replace(entityType.Name, ""); - if ((mapType & MapType.Map) > 0) - { - var expr = config.CreateMapExpression(tuple, MapType.Map); - translator.VisitLambda( - expr, - isHelperClass - ? ExpressionTranslator.LambdaType.PublicMethod - : ExpressionTranslator.LambdaType.ExtensionMethod, - "AdaptTo" + name - ); - } - - if ((mapType & MapType.MapToTarget) > 0) - { - var expr2 = config.CreateMapExpression(tuple, MapType.MapToTarget); - translator.VisitLambda( - expr2, - isHelperClass - ? ExpressionTranslator.LambdaType.PublicMethod - : ExpressionTranslator.LambdaType.ExtensionMethod, - "AdaptTo" - ); - } - - if ((mapType & MapType.Projection) > 0) - { - var proj = config.CreateMapExpression(tuple, MapType.Projection); - translator.VisitLambda( - proj, - ExpressionTranslator.LambdaType.PublicLambda, - "ProjectTo" + name - ); - } - } - - private static string GetCodeFriendlyTypeName(Type type) => - GetCodeFriendlyTypeName(new StringBuilder(), type).ToString(); - - private static StringBuilder GetCodeFriendlyTypeName(StringBuilder sb, Type type) - { - foreach (var subType in type.GenericTypeArguments) - { - GetCodeFriendlyTypeName(sb, subType); - } - - if (type.IsArray) - { - GetCodeFriendlyTypeName(sb, type.GetElementType()!); - sb.Append("Array"); - return sb; - } - - var name = type.Name; - var i = name.IndexOf('`'); - if (i > 0) - name = name.Remove(i); - name = name switch - { - "SByte" => "Sbyte", - "Int16" => "Short", - "UInt16" => "Ushort", - "Int32" => "Int", - "UInt32" => "Uint", - "Int64" => "Long", - "UInt64" => "Ulong", - "Single" => "Float", - "Boolean" => "Bool", - _ => name, - }; - - if (!string.IsNullOrEmpty(name)) - sb.Append(name); - return sb; + Generators.GenerateMappers(options); } } } From 77b53add260fd32e4dbb6c6d67d92ecdbe414452 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 09:30:07 +0500 Subject: [PATCH 26/32] feat(test): Improvements in Mapster.Tool.Tests : - sync TFM with Mapster.Tool - added ConfigHelpers for testing --- src/Mapster.Tool.Tests/Helpers/ConfigHelpers.cs | 11 +++++++++++ src/Mapster.Tool.Tests/Mapster.Tool.Tests.csproj | 6 +----- src/Mapster.Tool.Tests/Usings.cs | 4 +++- 3 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 src/Mapster.Tool.Tests/Helpers/ConfigHelpers.cs diff --git a/src/Mapster.Tool.Tests/Helpers/ConfigHelpers.cs b/src/Mapster.Tool.Tests/Helpers/ConfigHelpers.cs new file mode 100644 index 00000000..057a7518 --- /dev/null +++ b/src/Mapster.Tool.Tests/Helpers/ConfigHelpers.cs @@ -0,0 +1,11 @@ +using System.Reflection; + +namespace Mapster.Tool.Tests.Helpers +{ + internal static class ConfigHelpers + { + internal static MapperOptions optMappers => new MapperOptions() { Assembly = Assembly.GetExecutingAssembly().Location, Output = Path.GetTempPath() }; + internal static ModelOptions optModels = new ModelOptions() { Assembly = Assembly.GetExecutingAssembly().Location, Output = Path.GetTempPath() }; + internal static ExtensionOptions optExtentions = new ExtensionOptions() { Assembly = Assembly.GetExecutingAssembly().Location, Output = Path.GetTempPath() }; + } +} diff --git a/src/Mapster.Tool.Tests/Mapster.Tool.Tests.csproj b/src/Mapster.Tool.Tests/Mapster.Tool.Tests.csproj index 32897bb6..2b2aefee 100644 --- a/src/Mapster.Tool.Tests/Mapster.Tool.Tests.csproj +++ b/src/Mapster.Tool.Tests/Mapster.Tool.Tests.csproj @@ -1,17 +1,13 @@  - net10.0;net9.0;net8.0 + $(MapsterToolTFMs) enable enable true false - - $(TargetFrameworks);net48 - - diff --git a/src/Mapster.Tool.Tests/Usings.cs b/src/Mapster.Tool.Tests/Usings.cs index 8c927eb7..3bfc7862 100644 --- a/src/Mapster.Tool.Tests/Usings.cs +++ b/src/Mapster.Tool.Tests/Usings.cs @@ -1 +1,3 @@ -global using Xunit; \ No newline at end of file +global using Xunit; +global using Mapster.Tool; +global using Mapster.Tool.Tests.Helpers; \ No newline at end of file From 5d0bea04a56448e7b0a9a5624d19606141a3a3c3 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 09:33:16 +0500 Subject: [PATCH 27/32] feat(test): added test for #1017 --- ...pingWithExistingObjectAndInitProperties.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/Mapster.Tool.Tests/WhenMappingWithExistingObjectAndInitProperties.cs b/src/Mapster.Tool.Tests/WhenMappingWithExistingObjectAndInitProperties.cs index 7908b453..c106c6d2 100644 --- a/src/Mapster.Tool.Tests/WhenMappingWithExistingObjectAndInitProperties.cs +++ b/src/Mapster.Tool.Tests/WhenMappingWithExistingObjectAndInitProperties.cs @@ -22,6 +22,58 @@ public void MapWithReflection() userMapper.MapTo(user, dto); dto.Name.Should().Be(expected); } + + /// + /// https://github.com/MapsterMapper/Mapster/issues/1017 + /// + [Fact] + public void CreateDtoWithcustomResolver() + { + var mappers = new List(); + + Generators.GenerateExtensions(ConfigHelpers.optExtentions, mappers); + + var result = mappers.Where(x => x.Contains("User1017Dto AdaptToDto(this User1017")).FirstOrDefault(); + + result.Should().NotBeNullOrEmpty(); + result.Contains("FullName = string.Format(\"{0} {1}\", p1.FirstName, p1.LastName)").Should().BeTrue(); + } +} + + + +public class User1017 +{ + public int Id { get; set; } + public string Email { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public int Age { get; set; } +} + +public partial class User1017Dto +{ + public int Id { get; set; } + public string Email { get; set; } + public string FullName { get; set; } + public int Age { get; set; } +} + + +public class UserCodeGenConfig : ICodeGenerationRegister +{ + public void Register(CodeGenerationConfig config) + { + config.AdaptTo("[name]Dto", MapType.Map) + .ForType(p => + { + p.Ignore(s => s.FirstName); + p.Map(s => s.LastName, s => $"{s.FirstName} {s.LastName}", "FullName"); + }); + + config.GenerateMapper("[name]Mapper") + .ForType(); + } } public class UserMappingRegister : IRegister From 01402bb07fd26ddd8bdfa64b1e58a991af00bf77 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 09:38:01 +0500 Subject: [PATCH 28/32] fix: #1017 - now Mapster.Tool create custom resolver for destination member --- src/Mapster.Tool/Generators.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Mapster.Tool/Generators.cs b/src/Mapster.Tool/Generators.cs index 64522eff..afa051b3 100644 --- a/src/Mapster.Tool/Generators.cs +++ b/src/Mapster.Tool/Generators.cs @@ -490,7 +490,6 @@ Dictionary settings new InvokerModel { DestinationMemberName = setting.TargetPropertyName ?? name, - SourceMemberName = name, Invoker = setting.MapFunc, } ); From d31dc5da3c4bc397284409ca2d784f5b45b16cb9 Mon Sep 17 00:00:00 2001 From: Viktor Budahazi Date: Fri, 18 Sep 2026 16:57:43 +0200 Subject: [PATCH 29/32] perf: index current members when filtering hidden members --- .../WhenLookingUpHiddenMembers.cs | 285 ++++++++++++++++++ src/Mapster/Utils/ReflectionUtils.cs | 14 +- 2 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 src/Mapster.Tests/WhenLookingUpHiddenMembers.cs diff --git a/src/Mapster.Tests/WhenLookingUpHiddenMembers.cs b/src/Mapster.Tests/WhenLookingUpHiddenMembers.cs new file mode 100644 index 00000000..8743296b --- /dev/null +++ b/src/Mapster.Tests/WhenLookingUpHiddenMembers.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Shouldly; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mapster.Tests +{ + [TestClass] + public class WhenLookingUpHiddenMembers + { + [TestMethod] + public void DropHiddenMembers_CurrentMembers_AreEnumeratedOnce() + { + var members = typeof(Source).GetProperties().Cast().ToArray(); + var current = new CountingCollection(members); + var result = Filter(members, current); + + current.Enumerations.ShouldBe(0); + using (result.GetEnumerator()) + current.Enumerations.ShouldBe(0); + result.ToArray().ShouldBe(members); + current.Enumerations.ShouldBe(1); + result.ToArray().ShouldBe(members); + current.Enumerations.ShouldBe(2); + } + + [TestMethod] + public void DropHiddenMembers_Source_IsEnumeratedOnce() + { + var members = typeof(Source).GetProperties().Cast().ToArray(); + var visits = 0; + var source = members.Select(member => + { + visits++; + return member; + }); + + var result = Filter(source, members); + visits.ShouldBe(0); + using (result.GetEnumerator()) + visits.ShouldBe(0); + result.ToArray().ShouldBe(members); + visits.ShouldBe(members.Length); + } + + [TestMethod] + public void DropHiddenMembers_PartialEnumeration_DoesNotTraverseRemainingSource() + { + var member = typeof(Source).GetProperty(nameof(Source.First)); + var visits = 0; + var source = Enumerable.Repeat(member, 10).Select(item => + { + visits++; + return item; + }); + + Filter(source, new[] { member }).First().ShouldBeSameAs(member); + visits.ShouldBe(1); + } + + [TestMethod] + public void DropHiddenMembers_HiddenProperty_PreservesInheritedMembersAndOrder() + { + var inherited = typeof(BaseSource).GetProperty(nameof(BaseSource.Inherited)); + var hidden = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var visible = typeof(DerivedSource).GetProperty(nameof(DerivedSource.Value), BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + MemberInfo[] members = { inherited, hidden, visible, inherited }; + + Filter(members, new MemberInfo[] { visible }).ShouldBe(new MemberInfo[] { inherited, visible, inherited }); + } + + [TestMethod] + public void DropHiddenMembers_FieldHidesProperty_PreservesMetadataTokenSelection() + { + var hidden = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var visible = typeof(FieldSource).GetField(nameof(FieldSource.Value)); + + Filter(new MemberInfo[] { hidden, visible }, new MemberInfo[] { visible }).ShouldBe(new MemberInfo[] { visible }); + } + + [TestMethod] + public void DropHiddenMembers_DuplicateCurrentNames_FirstMemberWins() + { + var hidden = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var visible = typeof(DerivedSource).GetProperty(nameof(DerivedSource.Value), BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + Filter(new MemberInfo[] { hidden, visible }, new MemberInfo[] { visible, hidden }).ShouldBe(new MemberInfo[] { visible }); + Filter(new MemberInfo[] { hidden, visible }, new MemberInfo[] { hidden, visible }).ShouldBe(new MemberInfo[] { hidden }); + } + + [TestMethod] + public void DropHiddenMembers_DifferentlyCasedNames_AreDistinct() + { + var upper = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var lower = typeof(CaseSource).GetProperty(nameof(CaseSource.value)); + + Filter(new MemberInfo[] { upper, lower }, new MemberInfo[] { lower }).ShouldBe(new MemberInfo[] { upper, lower }); + } + + [TestMethod] + public void DropHiddenMembers_EmptyCurrentMembers_PreservesAllMembers() + { + MemberInfo[] members = typeof(BaseSource).GetProperties(); + + Filter(members, Array.Empty()).ShouldBe(members); + Filter(Array.Empty(), members).ShouldBeEmpty(); + Filter(Array.Empty(), Array.Empty()).ShouldBeEmpty(); + } + + [TestMethod] + public void DropHiddenMembers_PrivateMemberHidesPublicMember_PreservesSelection() + { + var hidden = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var visible = typeof(PrivateSource).GetProperty("Value", BindingFlags.NonPublic | BindingFlags.Instance); + + Filter(new MemberInfo[] { hidden }, new MemberInfo[] { visible }).ShouldBeEmpty(); + } + + [TestMethod] + public void DropHiddenMembers_ReenumeratedResult_RecomputesSelection() + { + var first = typeof(BaseSource).GetProperty(nameof(BaseSource.Value)); + var second = typeof(DerivedSource).GetProperty(nameof(DerivedSource.Value), BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + var current = new List { second }; + var result = Filter(new MemberInfo[] { first, second }, current); + + result.ToArray().ShouldBe(new MemberInfo[] { second }); + current[0] = first; + result.ToArray().ShouldBe(new MemberInfo[] { first }); + } + + [TestMethod] + public void Adapt_HiddenProperty_UsesDerivedValueAndInheritedProperty() + { + var config = new TypeAdapterConfig(); + config.NewConfig(); + config.Compile(); + var source = new DerivedSource { Value = "derived", Inherited = 42 }; + ((BaseSource)source).Value = 7; + + var result = source.Adapt(config); + var target = source.Adapt(new Destination(), config); + + result.Value.ShouldBe("derived"); + result.Inherited.ShouldBe(42); + target.Value.ShouldBe("derived"); + target.Inherited.ShouldBe(42); + } + + [TestMethod] + public void DropHiddenMembers_PropertyHidesField_PreservesSelection() + { + var hidden = typeof(FieldSource).GetField(nameof(FieldSource.Value)); + var visible = typeof(PropertySource).GetProperty(nameof(PropertySource.Value), BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + Filter(new MemberInfo[] { hidden, visible }, new MemberInfo[] { visible }) + .ShouldBe(new MemberInfo[] { visible }); + } + + [TestMethod] + public void DropHiddenMembers_NameAccesses_GrowLinearly() + { + const int count = 64; + var members = Enumerable.Range(0, count) + .Select(index => new CountingMember("Member" + index, index)).ToArray(); + + Filter(members, members).ToArray().ShouldBe(members); + + // Allow a constant number of name reads per member, but not a scan per match. + members.Sum(member => member.NameReads).ShouldBeLessThanOrEqualTo(8 * count); + } + + [TestMethod] + public void DropHiddenMembers_UnmatchedCurrentMember_DoesNotReadMetadataToken() + { + var source = typeof(Source).GetProperty(nameof(Source.First)); + var unmatched = new CountingMember("Unmatched"); + + Filter(new MemberInfo[] { source }, new MemberInfo[] { unmatched }) + .ShouldBe(new MemberInfo[] { source }); + } + + private sealed class CountingMember : MemberInfo + { + private readonly string _name; + private readonly int? _token; + + public CountingMember(string name, int? token = null) + { + _name = name; + _token = token; + } + + public int NameReads { get; private set; } + public override string Name + { + get + { + NameReads++; + return _name; + } + } + + public override int MetadataToken => _token ?? throw new InvalidOperationException("Unexpected token access"); + public override Type DeclaringType => typeof(Source); + public override Type ReflectedType => typeof(Source); + public override MemberTypes MemberType => MemberTypes.Property; + public override object[] GetCustomAttributes(bool inherit) => throw new NotSupportedException(); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => throw new NotSupportedException(); + public override bool IsDefined(Type attributeType, bool inherit) => throw new NotSupportedException(); + } + + private static IEnumerable Filter(IEnumerable source, ICollection current) + { + return source.DropHiddenMembers(current); + } + + private sealed class CountingCollection : ICollection + { + private readonly MemberInfo[] _members; + public CountingCollection(MemberInfo[] members) => _members = members; + public int Enumerations { get; private set; } + public int Count => _members.Length; + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() + { + Enumerations++; + return ((IEnumerable)_members).GetEnumerator(); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public bool Contains(MemberInfo item) => _members.Contains(item); + public void CopyTo(MemberInfo[] array, int index) => _members.CopyTo(array, index); + public void Add(MemberInfo item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Remove(MemberInfo item) => throw new NotSupportedException(); + } + + public class BaseSource + { + public int Value { get; set; } + public int Inherited { get; set; } + } + + public class DerivedSource : BaseSource + { + public new string Value { get; set; } + } + + public class FieldSource : BaseSource + { + public new int Value; + } + + public class CaseSource : BaseSource + { + public int value { get; set; } + } + + public class PropertySource : FieldSource + { + public new string Value { get; set; } + } + + public class PrivateSource : BaseSource + { + private new int Value { get; set; } + } + + public class Destination + { + public string Value { get; set; } + public int Inherited { get; set; } + } + + public class Source + { + public int First { get; set; } + public int Second { get; set; } + public int Third { get; set; } + } + } +} diff --git a/src/Mapster/Utils/ReflectionUtils.cs b/src/Mapster/Utils/ReflectionUtils.cs index 8203858f..c1fcc92a 100644 --- a/src/Mapster/Utils/ReflectionUtils.cs +++ b/src/Mapster/Utils/ReflectionUtils.cs @@ -99,16 +99,18 @@ IEnumerable GetFieldsFunc(Type t, MemberInfo[] overlapMembers) = public static IEnumerable DropHiddenMembers(this IEnumerable allMembers, ICollection currentTypeMembers) where T : MemberInfo { - var compareMemberNames = LinqCompat.IntersectBy( - allMembers, - currentTypeMembers.Select(x => x.Name), - x => x.Name).Select(x => x.Name); + var firstMembersByName = new Dictionary(StringComparer.Ordinal); + foreach (var member in currentTypeMembers) + { + if (!firstMembersByName.ContainsKey(member.Name)) + firstMembersByName.Add(member.Name, member); + } foreach (var member in allMembers) { - if (compareMemberNames.Contains(member.Name)) + if (firstMembersByName.TryGetValue(member.Name, out var currentMember)) { - if (currentTypeMembers.First(x => x.Name == member.Name).MetadataToken == member.MetadataToken) + if (currentMember.MetadataToken == member.MetadataToken) yield return member; } else From 5075c16b870735f0c2b24b32d60607dbc0377a50 Mon Sep 17 00:00:00 2001 From: Viktor Budahazi Date: Sat, 19 Sep 2026 12:02:15 +0200 Subject: [PATCH 30/32] perf: reuse hidden-member lookup across filtering passes --- src/Mapster/Utils/ReflectionUtils.cs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Mapster/Utils/ReflectionUtils.cs b/src/Mapster/Utils/ReflectionUtils.cs index c1fcc92a..fe7bc6bd 100644 --- a/src/Mapster/Utils/ReflectionUtils.cs +++ b/src/Mapster/Utils/ReflectionUtils.cs @@ -80,24 +80,33 @@ public static IEnumerable GetFieldsAndProperties(this Type type, BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, (x, y) => true, type.FullName); + var firstMembersByName = CreateFirstMembersByName(currentTypeMembers); + if (type.GetTypeInfo().IsInterface) { var allInterfaces = GetAllInterfaces(type); - return allInterfaces.SelectMany(x => GetPropertiesFunc(x, currentTypeMembers)); + return allInterfaces.SelectMany(GetPropertiesFunc); } - return GetPropertiesFunc(type, currentTypeMembers).Concat(GetFieldsFunc(type, currentTypeMembers)); + return GetPropertiesFunc(type).Concat(GetFieldsFunc(type)); - IEnumerable GetPropertiesFunc(Type t, MemberInfo[] currentTypeMembers) => t.GetProperties(bindingFlags) - .Where(x => x.GetIndexParameters().Length == 0).DropHiddenMembers(currentTypeMembers) + IEnumerable GetPropertiesFunc(Type t) => t.GetProperties(bindingFlags) + .Where(x => x.GetIndexParameters().Length == 0).DropHiddenMembers(firstMembersByName) .Select(CreateModel); - IEnumerable GetFieldsFunc(Type t, MemberInfo[] overlapMembers) => - t.GetFields(bindingFlags).DropHiddenMembers(overlapMembers) + IEnumerable GetFieldsFunc(Type t) => + t.GetFields(bindingFlags).DropHiddenMembers(firstMembersByName) .Select(CreateModel); } public static IEnumerable DropHiddenMembers(this IEnumerable allMembers, ICollection currentTypeMembers) where T : MemberInfo + { + var firstMembersByName = CreateFirstMembersByName(currentTypeMembers); + foreach (var member in allMembers.DropHiddenMembers(firstMembersByName)) + yield return member; + } + + private static Dictionary CreateFirstMembersByName(ICollection currentTypeMembers) { var firstMembersByName = new Dictionary(StringComparer.Ordinal); foreach (var member in currentTypeMembers) @@ -106,6 +115,11 @@ public static IEnumerable DropHiddenMembers(this IEnumerable allMembers firstMembersByName.Add(member.Name, member); } + return firstMembersByName; + } + + private static IEnumerable DropHiddenMembers(this IEnumerable allMembers, Dictionary firstMembersByName) where T : MemberInfo + { foreach (var member in allMembers) { if (firstMembersByName.TryGetValue(member.Name, out var currentMember)) From 567328503e4c9bf0052ad3a5e9822834d6da8255 Mon Sep 17 00:00:00 2001 From: vb-kalei Date: Tue, 22 Sep 2026 11:26:09 +0200 Subject: [PATCH 31/32] perf: cache source member attribute metadata during compilationPerf/compilation scoped attribute cache (#1022) * perf: cache source member attribute metadata during compilation --- .../WhenCachingAttributeMetadata.cs | 412 ++++++++++++++++++ src/Mapster/Compile/AttributeMetadataCache.cs | 40 ++ src/Mapster/Compile/CompileContext.cs | 2 + src/Mapster/Models/FieldModel.cs | 9 +- src/Mapster/Models/PropertyModel.cs | 9 +- .../Settings/ValueAccessingStrategy.cs | 3 +- src/Mapster/TypeAdapterConfig.cs | 1 + src/Mapster/Utils/ReflectionUtils.cs | 7 +- 8 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 src/Mapster.Tests/WhenCachingAttributeMetadata.cs create mode 100644 src/Mapster/Compile/AttributeMetadataCache.cs diff --git a/src/Mapster.Tests/WhenCachingAttributeMetadata.cs b/src/Mapster.Tests/WhenCachingAttributeMetadata.cs new file mode 100644 index 00000000..0afdd1a8 --- /dev/null +++ b/src/Mapster.Tests/WhenCachingAttributeMetadata.cs @@ -0,0 +1,412 @@ +using Mapster.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace Mapster.Tests +{ + [TestClass] + public class WhenCachingAttributeMetadata + { + [TestMethod] + public void Metadata_Is_Shared_By_Wrappers_Only_Within_A_Context() + { + var first = new CompileContext(new TypeAdapterConfig()); + var second = new CompileContext(new TypeAdapterConfig()); + var property = typeof(Source).GetProperty(nameof(Source.Original)); + var field = typeof(Source).GetField(nameof(Source.Field)); + var propertyModel = new PropertyModel(property, first.AttributeMetadata); + var fieldModel = new FieldModel(field, first.AttributeMetadata); + + propertyModel.GetType().ShouldBe(typeof(PropertyModel)); + fieldModel.GetType().ShouldBe(typeof(FieldModel)); + propertyModel.GetCustomAttributesData().ShouldBeSameAs( + new PropertyModel(property, first.AttributeMetadata).GetCustomAttributesData()); + fieldModel.GetCustomAttributesData().ShouldBeSameAs( + new FieldModel(field, first.AttributeMetadata).GetCustomAttributesData()); + propertyModel.GetCustomAttributesData().ShouldNotBeSameAs( + new PropertyModel(property, second.AttributeMetadata).GetCustomAttributesData()); + fieldModel.GetCustomAttributesData().ShouldNotBeSameAs( + new FieldModel(field, second.AttributeMetadata).GetCustomAttributesData()); + new CompileArgument { Context = first }.CloneWith(MapType.MapToTarget).Context.ShouldBeSameAs(first); + } + + [TestMethod] + public void Metadata_Is_Read_Only_And_Preserves_Reflection_Order() + { + var cache = new AttributeMetadataCache(); + foreach (var member in new MemberInfo[] + { + typeof(Source).GetProperty(nameof(Source.Original)), + typeof(Source).GetField(nameof(Source.Field)), + typeof(Source).GetProperty(nameof(Source.Plain)) + }) + { + var metadata = cache.Get(member); + metadata.ShouldBeSameAs(cache.Get(member)); + metadata.Select(x => x.AttributeType).ShouldBe(member.GetCustomAttributesData().Select(x => x.AttributeType)); + var list = (IList)metadata; + list.IsReadOnly.ShouldBeTrue(); + Should.Throw(() => list.Add(null)); + } + } + + [TestMethod] + public void Metadata_Distinguishes_Closed_Generic_And_Hidden_Members() + { + var cache = new AttributeMetadataCache(); + var members = new MemberInfo[] + { + typeof(GenericSource).GetProperty(nameof(GenericSource.Value)), + typeof(GenericSource).GetProperty(nameof(GenericSource.Value)), + typeof(BaseSource).GetProperty(nameof(BaseSource.Value)), + typeof(DerivedSource).GetProperty(nameof(DerivedSource.Value)), + typeof(BaseSource).GetProperty(nameof(BaseSource.Inherited)), + typeof(DerivedSource).GetProperty(nameof(BaseSource.Inherited)) + }; + for (var i = 0; i < members.Length; i++) + { + cache.Get(members[i]).Select(x => x.AttributeType) + .ShouldBe(members[i].GetCustomAttributesData().Select(x => x.AttributeType)); + for (var j = 0; j < i; j++) + cache.Get(members[i]).ShouldNotBeSameAs(cache.Get(members[j])); + } + } + + [TestMethod] + public void Models_Are_Lazy_And_Public_Construction_Remains_Uncached() + { + var property = new CountingProperty(typeof(Source).GetProperty(nameof(Source.Original))); + var cache = new AttributeMetadataCache(); + var cached = new PropertyModel(property, cache); + var uncached = new PropertyModel(property); + property.Reads.ShouldBe(0); + cached.GetCustomAttributesData(); + cached.GetCustomAttributesData(); + property.Reads.ShouldBe(1); + uncached.GetCustomAttributesData(); + uncached.GetCustomAttributesData(); + property.Reads.ShouldBe(3); + cache.Complete(); + cache.Complete(); + cached.GetCustomAttributesData(); + cached.GetCustomAttributesData(); + property.Reads.ShouldBe(5); + } + + [TestMethod] + public void Failed_Metadata_Retrieval_Is_Not_Cached() + { + var property = new CountingProperty(typeof(Source).GetProperty(nameof(Source.Original))) { Fail = true }; + var cache = new AttributeMetadataCache(); + Should.Throw(() => cache.Get(property)); + property.Fail = false; + cache.Get(property).ShouldHaveSingleItem(); + cache.Get(property); + property.Reads.ShouldBe(2); + } + + [TestMethod] + public void Attribute_Instances_Are_Created_Per_Lookup() + { + var model = new PropertyModel(typeof(Source).GetProperty(nameof(Source.Original)), new AttributeMetadataCache()); + var first = model.GetCustomAttributeFromData(); + var second = model.GetCustomAttributeFromData(); + first.ShouldNotBeSameAs(second); + first.Name.ShouldBe(second.Name); + model.GetCustomAttributes(true).Single().ShouldNotBeSameAs(model.GetCustomAttributes(true).Single()); + } + + [TestMethod] + [DataRow(MapType.Map, false)] + [DataRow(MapType.MapToTarget, false)] + [DataRow(MapType.Projection, false)] + [DataRow(MapType.Map, true)] + [DataRow(MapType.MapToTarget, true)] + [DataRow(MapType.Projection, true)] + public void Compilation_Releases_Metadata_Even_With_Retained_Member_And_Exception(MapType mapType, bool fail) + { + var retained = CompileAndRetain(mapType, fail); + Collect(); + retained.Metadata.IsAlive.ShouldBeFalse(); + retained.Member.GetCustomAttributesData().Single().AttributeType.ShouldBe(typeof(AdaptMemberAttribute)); + var property = new CountingProperty(typeof(Source).GetProperty(nameof(Source.Original))); + retained.Context.AttributeMetadata.Get(property); + retained.Context.AttributeMetadata.Get(property); + property.Reads.ShouldBe(2); + GC.KeepAlive(retained); + } + + // Keep stack locals out of the collection assertion; retain the same objects a callback or exception can expose. + [MethodImpl(MethodImplOptions.NoInlining)] + private static RetainedCompilation CompileAndRetain(MapType mapType, bool fail) + { + var retained = new RetainedCompilation(); + var config = new TypeAdapterConfig(); + config.Default.Settings.ValueAccessingStrategies.Add((source, destination, arg) => + { + retained.Context = arg.Context; + return null; + }); + config.NewConfig().IgnoreMember((member, side) => + { + if (side == MemberSide.Source && member.Name == nameof(Source.Original)) + { + var metadata = member.GetCustomAttributesData(); + // Flattening also invokes this callback, but deliberately uses uncached models. + if (!ReferenceEquals(metadata, retained.Context.AttributeMetadata.Get((MemberInfo)member.Info))) + return false; + retained.Member = member; + retained.Metadata = new WeakReference(metadata); + if (fail) + throw new InvalidOperationException("Expected test failure"); + } + return false; + }); + var tuple = new TypeTuple(typeof(Source), typeof(Destination)); + if (fail) + { + retained.Exception = Should.Throw(() => config.CreateMapExpression(tuple, mapType)); + retained.Exception.Argument.Context.ShouldBeSameAs(retained.Context); + } + else + { + config.CreateMapExpression(tuple, mapType); + } + retained.Member.ShouldNotBeNull(); + retained.Context.ShouldNotBeNull(); + return retained; + } + + [TestMethod] + public void Completed_Cache_Releases_Member_Keys_As_Well_As_Values() + { + var cache = new AttributeMetadataCache(); + var references = Populate(cache); + cache.Complete(); + Collect(); + references.All(x => !x.IsAlive).ShouldBeTrue(); + GC.KeepAlive(cache); + } + + [TestMethod] + public void Generated_Delegate_Does_Not_Retain_The_Compilation_Cache() + { + var references = new List(); + var map = CompileAndObserve(references); + references.Count.ShouldBeGreaterThan(0); + Collect(); + references.All(x => !x.IsAlive).ShouldBeTrue(); + map(new Source { Original = 7 }).Renamed.ShouldBe(7); + GC.KeepAlive(map); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static Func CompileAndObserve(List references) + { + var config = new TypeAdapterConfig(); + config.Default.Settings.ValueAccessingStrategies.Add((source, destination, arg) => + { + references.Add(new WeakReference(arg.Context)); + references.Add(new WeakReference(arg.Context.AttributeMetadata)); + return null; + }); + config.NewConfig(); + return config.GetMapFunction(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference[] Populate(AttributeMetadataCache cache) + { + var member = new CountingProperty(typeof(Source).GetProperty(nameof(Source.Original))); + return new[] { new WeakReference(member), new WeakReference(cache.Get(member)) }; + } + + private static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + [TestMethod] + public void Mapping_Preserves_Renames_Ignores_And_Configuration_Isolation() + { + var source = new Source { Original = 7, Field = 8, Ignored = 9, Plain = 10 }; + var first = new TypeAdapterConfig(); + first.NewConfig(); + first.Compile(); + var result = source.Adapt(first); + var target = source.Adapt(new Destination { Ignored = 42 }, first); + result.Renamed.ShouldBe(7); + result.Field.ShouldBe(8); + result.Ignored.ShouldBe(0); + result.Plain.ShouldBe(10); + target.Renamed.ShouldBe(7); + target.Field.ShouldBe(8); + target.Ignored.ShouldBe(42); + var second = new TypeAdapterConfig(); + second.NewConfig().Map(x => x.Renamed, x => x.Original + 1); + second.Compile(); + source.Adapt(second).Renamed.ShouldBe(8); + source.Adapt(first).Renamed.ShouldBe(7); + first.CompileProjection(); + new[] { source }.AsQueryable().ProjectToType(first).Single().Renamed.ShouldBe(7); + } + + [TestMethod] + public void Nested_Mappings_And_Forks_Share_Only_The_Root_Context() + { + var contexts = new List(); + var sourceTypes = new HashSet(); + var configs = new List(); + var config = new TypeAdapterConfig(); + config.Default.Settings.ValueAccessingStrategies.Add((source, destination, arg) => + { + contexts.Add(arg.Context); + sourceTypes.Add(arg.SourceType); + configs.Add(arg.Context.Config); + return null; + }); + config.NewConfig() + .Fork(child => child.ForType().Ignore(x => x.Plain)); + var tuple = new TypeTuple(typeof(ContainerSource), typeof(ContainerDestination)); + var roots = new List(); + foreach (var mapType in new[] { MapType.Map, MapType.MapToTarget, MapType.Projection }) + { + contexts.Clear(); + config.CreateMapExpression(tuple, mapType); + contexts.Count.ShouldBeGreaterThan(1); + contexts.Distinct().ShouldHaveSingleItem(); + roots.Add(contexts[0]); + } + roots.Distinct().Count().ShouldBe(3); + sourceTypes.ShouldContain(typeof(ContainerSource)); + sourceTypes.ShouldContain(typeof(Source)); + configs.All(x => x != config).ShouldBeTrue(); + var source = new ContainerSource { First = new Source { Original = 7, Plain = 9 }, Second = new Source { Original = 8 } }; + var result = source.Adapt(config); + result.First.Renamed.ShouldBe(7); + result.Second.Renamed.ShouldBe(8); + result.First.Plain.ShouldBe(0); + source.First.Adapt(config).Plain.ShouldBe(9); + } + + [TestMethod] + public void Independent_Compilations_Do_Not_Share_Caches_Or_Decisions() + { + var contexts = new CompileContext[8]; + Parallel.For(0, contexts.Length, i => + { + var config = new TypeAdapterConfig(); + config.Default.Settings.ValueAccessingStrategies.Add((source, destination, arg) => + { + contexts[i] = arg.Context; + return null; + }); + config.NewConfig().Map(x => x.Plain, x => x.Plain + i); + config.Compile(); + new Source { Original = 7, Plain = 10 }.Adapt(config).Plain.ShouldBe(10 + i); + }); + contexts.All(x => x != null).ShouldBeTrue(); + contexts.Select(x => x.AttributeMetadata).Distinct().Count().ShouldBe(contexts.Length); + } + + private sealed class RetainedCompilation + { + public CompileContext Context; + public IMemberModel Member; + public WeakReference Metadata; + public CompileException Exception; + } + + private sealed class CountingProperty : PropertyInfo + { + private readonly PropertyInfo _property; + public int Reads { get; private set; } + public bool Fail { get; set; } + public CountingProperty(PropertyInfo property) => _property = property; + public override IList GetCustomAttributesData() + { + Reads++; + if (Fail) + throw new InvalidOperationException("Expected metadata failure"); + return _property.GetCustomAttributesData(); + } + public override string Name => _property.Name; + public override Type DeclaringType => _property.DeclaringType; + public override Type ReflectedType => _property.ReflectedType; + public override Type PropertyType => _property.PropertyType; + public override PropertyAttributes Attributes => _property.Attributes; + public override bool CanRead => _property.CanRead; + public override bool CanWrite => _property.CanWrite; + public override MethodInfo[] GetAccessors(bool nonPublic) => _property.GetAccessors(nonPublic); + public override MethodInfo GetGetMethod(bool nonPublic) => _property.GetGetMethod(nonPublic); + public override MethodInfo GetSetMethod(bool nonPublic) => _property.GetSetMethod(nonPublic); + public override ParameterInfo[] GetIndexParameters() => _property.GetIndexParameters(); + public override object[] GetCustomAttributes(bool inherit) => _property.GetCustomAttributes(inherit); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => _property.GetCustomAttributes(attributeType, inherit); + public override bool IsDefined(Type attributeType, bool inherit) => _property.IsDefined(attributeType, inherit); + public override object GetValue(object obj, BindingFlags invokeAttr, Binder binder, object[] index, System.Globalization.CultureInfo culture) + => _property.GetValue(obj, invokeAttr, binder, index, culture); + public override void SetValue(object obj, object value, BindingFlags invokeAttr, Binder binder, object[] index, System.Globalization.CultureInfo culture) + => _property.SetValue(obj, value, invokeAttr, binder, index, culture); + } + + public class Source + { + [AdaptMember("Renamed")] + public int Original { get; set; } + [AdaptMember("Field"), System.ComponentModel.Description("Metadata ordering fixture")] + public int Field; + [AdaptIgnore] + public int Ignored { get; set; } + public int Plain { get; set; } + } + + public class Destination + { + public int Renamed { get; set; } + public int Field; + public int Ignored { get; set; } + public int Plain { get; set; } + } + + public class GenericSource + { + [AdaptIgnore] + public T Value { get; set; } + } + + public class BaseSource + { + [AdaptIgnore] + public int Value { get; set; } + [AdaptMember("Name")] + public int Inherited { get; set; } + } + + public class DerivedSource : BaseSource + { + [AdaptMember("Renamed")] + public new int Value { get; set; } + } + + public class ContainerSource + { + public Source First { get; set; } + public Source Second { get; set; } + } + + public class ContainerDestination + { + public Destination First { get; set; } + public Destination Second { get; set; } + } + } +} diff --git a/src/Mapster/Compile/AttributeMetadataCache.cs b/src/Mapster/Compile/AttributeMetadataCache.cs new file mode 100644 index 00000000..1c367139 --- /dev/null +++ b/src/Mapster/Compile/AttributeMetadataCache.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Mapster +{ + // Shared by built-in source member models for one root expression, including its inline mappings. + // The lock protects this cache only; CompileContext's other mutable state is not thread-safe. + internal sealed class AttributeMetadataCache + { + private readonly Dictionary> _metadata = new(); + private bool _completed; + + internal IEnumerable Get(MemberInfo member) + { + lock (_metadata) + { + if (_completed) + return member.GetCustomAttributesData(); + if (!_metadata.TryGetValue(member, out var attributes)) + { + attributes = Array.AsReadOnly(new List(member.GetCustomAttributesData()).ToArray()); + _metadata.Add(member, attributes); + } + return attributes; + } + } + + internal void Complete() + { + lock (_metadata) + { + // Callbacks and CompileException can retain models/context after compilation. + // Release metadata and prevent retained models from repopulating the cache. + _completed = true; + _metadata.Clear(); + } + } + } +} diff --git a/src/Mapster/Compile/CompileContext.cs b/src/Mapster/Compile/CompileContext.cs index 72d640c8..6c0b3f48 100644 --- a/src/Mapster/Compile/CompileContext.cs +++ b/src/Mapster/Compile/CompileContext.cs @@ -14,6 +14,8 @@ public class CompileContext public HashSet ExtraParameters { get; } = new(); public HashSet<(Expression param, CompileArgument arg)> NullChecks { get; } = new(); + internal AttributeMetadataCache AttributeMetadata { get; } = new(); + internal bool IsSubFunction() { return MaxDepth.HasValue || ExtraParameters.Count > 0; diff --git a/src/Mapster/Models/FieldModel.cs b/src/Mapster/Models/FieldModel.cs index a96bc628..ed621bc6 100644 --- a/src/Mapster/Models/FieldModel.cs +++ b/src/Mapster/Models/FieldModel.cs @@ -8,11 +8,18 @@ namespace Mapster.Models public class FieldModel : IMemberModelEx { private readonly FieldInfo _fieldInfo; + private readonly AttributeMetadataCache? _attributeMetadata; public FieldModel(FieldInfo fieldInfo) { _fieldInfo = fieldInfo; } + internal FieldModel(FieldInfo fieldInfo, AttributeMetadataCache? attributeMetadata) + : this(fieldInfo) + { + _attributeMetadata = attributeMetadata; + } + public Type Type => _fieldInfo.FieldType; public string Name => _fieldInfo.Name; public object Info => _fieldInfo; @@ -33,7 +40,7 @@ public IEnumerable GetCustomAttributes(bool inherit) } public IEnumerable GetCustomAttributesData() { - return _fieldInfo.GetCustomAttributesData(); + return _attributeMetadata?.Get(_fieldInfo) ?? _fieldInfo.GetCustomAttributesData(); } } } diff --git a/src/Mapster/Models/PropertyModel.cs b/src/Mapster/Models/PropertyModel.cs index 0093946d..ba56f2fa 100644 --- a/src/Mapster/Models/PropertyModel.cs +++ b/src/Mapster/Models/PropertyModel.cs @@ -8,11 +8,18 @@ namespace Mapster.Models public class PropertyModel : IMemberModelEx { private readonly PropertyInfo _propertyInfo; + private readonly AttributeMetadataCache? _attributeMetadata; public PropertyModel(PropertyInfo propertyInfo) { _propertyInfo = propertyInfo; } + internal PropertyModel(PropertyInfo propertyInfo, AttributeMetadataCache? attributeMetadata) + : this(propertyInfo) + { + _attributeMetadata = attributeMetadata; + } + public Type Type => _propertyInfo.PropertyType; public virtual string Name => _propertyInfo.Name; public object Info => _propertyInfo; @@ -48,7 +55,7 @@ public IEnumerable GetCustomAttributes(bool inherit) } public IEnumerable GetCustomAttributesData() { - return _propertyInfo.GetCustomAttributesData(); + return _attributeMetadata?.Get(_propertyInfo) ?? _propertyInfo.GetCustomAttributesData(); } } } diff --git a/src/Mapster/Settings/ValueAccessingStrategy.cs b/src/Mapster/Settings/ValueAccessingStrategy.cs index 4fb608dc..bd165784 100644 --- a/src/Mapster/Settings/ValueAccessingStrategy.cs +++ b/src/Mapster/Settings/ValueAccessingStrategy.cs @@ -71,7 +71,8 @@ public static class ValueAccessingStrategy private static Expression? PropertyOrFieldFn(Expression source, IMemberModel destinationMember, CompileArgument arg) { - var members = source.Type.GetFieldsAndProperties(true); + // Repeated source scans create fresh wrappers; share metadata, not mapping decisions. + var members = source.Type.GetFieldsAndProperties(true, arg.Context.AttributeMetadata); var strategy = arg.Settings.NameMatchingStrategy; var destinationMemberName = destinationMember.GetMemberName(MemberSide.Destination, arg.Settings.GetMemberNames, strategy.DestinationMemberNameConverter, arg); return members diff --git a/src/Mapster/TypeAdapterConfig.cs b/src/Mapster/TypeAdapterConfig.cs index 08db194a..5c92b258 100644 --- a/src/Mapster/TypeAdapterConfig.cs +++ b/src/Mapster/TypeAdapterConfig.cs @@ -411,6 +411,7 @@ public LambdaExpression CreateMapExpression(TypeTuple tuple, MapType mapType) } finally { + context.AttributeMetadata.Complete(); if (fork != null) context.Configs.Pop(); context.Running.Remove(tuple); diff --git a/src/Mapster/Utils/ReflectionUtils.cs b/src/Mapster/Utils/ReflectionUtils.cs index fe7bc6bd..9d8a5b68 100644 --- a/src/Mapster/Utils/ReflectionUtils.cs +++ b/src/Mapster/Utils/ReflectionUtils.cs @@ -70,7 +70,7 @@ public static bool IsPoco(this Type type) return type.GetFieldsAndProperties().Any(it => (it.SetterModifier & (AccessModifier.Public | AccessModifier.NonPublic)) != 0); } - public static IEnumerable GetFieldsAndProperties(this Type type, bool includeNonPublic = false) + public static IEnumerable GetFieldsAndProperties(this Type type, bool includeNonPublic = false, AttributeMetadataCache? attributeMetadata = null) { var bindingFlags = BindingFlags.Instance | BindingFlags.Public; if (includeNonPublic) @@ -90,13 +90,14 @@ public static IEnumerable GetFieldsAndProperties(this Type type, return GetPropertiesFunc(type).Concat(GetFieldsFunc(type)); + IEnumerable GetPropertiesFunc(Type t) => t.GetProperties(bindingFlags) .Where(x => x.GetIndexParameters().Length == 0).DropHiddenMembers(firstMembersByName) - .Select(CreateModel); + .Select(x => new PropertyModel(x, attributeMetadata)); IEnumerable GetFieldsFunc(Type t) => t.GetFields(bindingFlags).DropHiddenMembers(firstMembersByName) - .Select(CreateModel); + .Select(x => new FieldModel(x, attributeMetadata)); } public static IEnumerable DropHiddenMembers(this IEnumerable allMembers, ICollection currentTypeMembers) where T : MemberInfo From 8caef785caa04ac2a7fb1d76f12de1a715225ec8 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Tue, 22 Sep 2026 14:43:22 +0500 Subject: [PATCH 32/32] chore: Bump version to v10.0.13 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fcff4b97..6b9263b3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ false - 10.0.13-pre02 + 10.0.13 netstandard2.0;net10.0;net9.0;net8.0 netstandard2.0;net10.0;net9.0;net8.0 net10.0;net9.0;net8.0