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 8203858f..1898a1da 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,11 +90,11 @@ public static IEnumerable GetFieldsAndProperties(this Type type, IEnumerable GetPropertiesFunc(Type t, MemberInfo[] currentTypeMembers) => t.GetProperties(bindingFlags) .Where(x => x.GetIndexParameters().Length == 0).DropHiddenMembers(currentTypeMembers) - .Select(CreateModel); + .Select(x => new PropertyModel(x, attributeMetadata)); IEnumerable GetFieldsFunc(Type t, MemberInfo[] overlapMembers) => t.GetFields(bindingFlags).DropHiddenMembers(overlapMembers) - .Select(CreateModel); + .Select(x => new FieldModel(x, attributeMetadata)); } public static IEnumerable DropHiddenMembers(this IEnumerable allMembers, ICollection currentTypeMembers) where T : MemberInfo