From 855537e9e0b2def6d823b68823a31e4d4f9b4418 Mon Sep 17 00:00:00 2001 From: DocSvartz Date: Mon, 7 Sep 2026 08:55:18 +0500 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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