diff --git a/src/Riok.Mapperly.Abstractions/MapperNestedPropertiesAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperNestedPropertiesAttribute.cs new file mode 100644 index 0000000000..bd7d982dba --- /dev/null +++ b/src/Riok.Mapperly.Abstractions/MapperNestedPropertiesAttribute.cs @@ -0,0 +1,37 @@ +namespace Riok.Mapperly.Abstractions; + +/// +/// Maps all properties from a nested path to the root destination. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class MapperNestedPropertiesAttribute : Attribute +{ + private const string PropertyAccessSeparatorStr = "."; + private const char PropertyAccessSeparator = '.'; + + /// + /// Maps a specified source property to the specified target property. + /// + /// The name of the source property. The use of `nameof()` is encouraged. A path can be specified by joining property names with a '.'. + public MapperNestedPropertiesAttribute(string source) + : this(source.Split(PropertyAccessSeparator)) { } + + /// + /// Maps a specified source property to the specified target property. + /// + /// The path of the source property. The use of `nameof()` is encouraged. + public MapperNestedPropertiesAttribute(string[] source) + { + Source = source; + } + + /// + /// Gets the name of the source property. + /// + public IReadOnlyCollection Source { get; } + + /// + /// Gets the full name of the source property path. + /// + public string SourceFullName => string.Join(PropertyAccessSeparatorStr, Source); +} diff --git a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt index 0bf47f01ee..2d159f51ce 100644 --- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt +++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt @@ -105,3 +105,8 @@ Riok.Mapperly.Abstractions.MapperIgnoreTargetValueAttribute Riok.Mapperly.Abstractions.MapperIgnoreTargetValueAttribute.MapperIgnoreTargetValueAttribute(object! target) -> void Riok.Mapperly.Abstractions.MapperIgnoreSourceValueAttribute.SourceValue.get -> System.Enum? Riok.Mapperly.Abstractions.MapperIgnoreTargetValueAttribute.TargetValue.get -> System.Enum? +Riok.Mapperly.Abstractions.MapperNestedPropertiesAttribute +Riok.Mapperly.Abstractions.MapperNestedPropertiesAttribute.MapperNestedPropertiesAttribute(string! source) -> void +Riok.Mapperly.Abstractions.MapperNestedPropertiesAttribute.MapperNestedPropertiesAttribute(string![]! source) -> void +Riok.Mapperly.Abstractions.MapperNestedPropertiesAttribute.Source.get -> System.Collections.Generic.IReadOnlyCollection! +Riok.Mapperly.Abstractions.MapperNestedPropertiesAttribute.SourceFullName.get -> string! diff --git a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs index bd5f65c54d..64b23a1be4 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs @@ -27,6 +27,7 @@ public MapperConfiguration(SymbolAccessor symbolAccessor, ISymbol mapperSymbol) Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Mapper.IgnoreObsoleteMembersStrategy ), Array.Empty() @@ -74,8 +75,14 @@ private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol metho var ignoreObsolete = _dataAccessor.Access(method).FirstOrDefault() is not { } methodIgnore ? _defaultConfiguration.Properties.IgnoreObsoleteMembersStrategy : methodIgnore.IgnoreObsoleteStrategy; - - return new PropertiesMappingConfiguration(ignoredSourceProperties, ignoredTargetProperties, explicitMappings, ignoreObsolete); + var nestedMappings = _dataAccessor.Access(method).ToList(); + return new PropertiesMappingConfiguration( + ignoredSourceProperties, + ignoredTargetProperties, + explicitMappings, + nestedMappings, + ignoreObsolete + ); } private EnumMappingConfiguration BuildEnumConfig(MappingConfigurationReference configRef) diff --git a/src/Riok.Mapperly/Configuration/NestedPropertyMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/NestedPropertyMappingConfiguration.cs new file mode 100644 index 0000000000..a4d71bba23 --- /dev/null +++ b/src/Riok.Mapperly/Configuration/NestedPropertyMappingConfiguration.cs @@ -0,0 +1,3 @@ +namespace Riok.Mapperly.Configuration; + +public record NestedPropertyMappingConfiguration(StringMemberPath Source); diff --git a/src/Riok.Mapperly/Configuration/PropertiesMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/PropertiesMappingConfiguration.cs index f991cc536a..6d539bc926 100644 --- a/src/Riok.Mapperly/Configuration/PropertiesMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/PropertiesMappingConfiguration.cs @@ -6,5 +6,6 @@ public record PropertiesMappingConfiguration( IReadOnlyCollection IgnoredSources, IReadOnlyCollection IgnoredTargets, IReadOnlyCollection ExplicitMappings, + IReadOnlyCollection NestedMappings, IgnoreObsoleteMembersStrategy IgnoreObsoleteMembersStrategy ); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs index c635e30708..5657b367a7 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs @@ -42,8 +42,6 @@ protected MembersMappingBuilderContext(MappingBuilderContext builderContext, T m _unmappedSourceMemberNames.ExceptWith(IgnoredSourceMemberNames); - MemberConfigsByRootTargetName = GetMemberConfigurations(); - // remove explicitly mapped ignored targets from ignoredTargetMemberNames // then remove all ignored targets from TargetMembers, leaving unignored and explicitly mapped ignored members ignoredTargetMemberNames.ExceptWith(MemberConfigsByRootTargetName.Keys); @@ -117,9 +115,25 @@ private Dictionary GetTargetMembers() private Dictionary> GetMemberConfigurations() { - return BuilderContext.Configuration.Properties.ExplicitMappings - .GroupBy(x => x.Target.Path.First()) - .ToDictionary(x => x.Key, x => x.ToList()); + var simpleMappings = BuilderContext.Configuration.Properties.ExplicitMappings.GroupBy(x => x.Target.Path.First()); + + var wildcardMappings = BuilderContext.Configuration.Properties.NestedMappings + .SelectMany(x => + { + return BuilderContext.SymbolAccessor + .GetAllAccessibleMappableMembers(Mapping.SourceType) + .Where(i => i.Name == x.Source.Path.Last()) + .SelectMany(i => BuilderContext.SymbolAccessor.GetAllAccessibleMappableMembers(i.Type)) + .Select(i => + { + var list = x.Source.Path.ToList(); + list.Add(i.Name); + return new PropertyMappingConfiguration(new StringMemberPath(list), new StringMemberPath(new[] { i.Name })); + }); + }) + .GroupBy(x => x.Target.Path.Last()); + + return simpleMappings.Union(wildcardMappings).ToDictionary(x => x.Key, x => x.ToList()); } private void AddUnmatchedIgnoredTargetMembersDiagnostics() diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs index fbc7c3c235..9c77d23bfc 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs @@ -208,6 +208,19 @@ public Task WithPropertyNameMappingStrategyCaseSensitive() return TestHelper.VerifyGenerator(source); } + [Fact] + public Task WithPropertyNameMappingStrategyCaseSensitiveAndManualMappedProperty() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(A.stringvalue), nameof(B.StringValue2)] partial B Map(A source);", + new TestSourceBuilderOptions { PropertyNameMappingStrategy = PropertyNameMappingStrategy.CaseSensitive }, + "class A { public string stringvalue { get; set; } }", + "class B { public string StringValue2 { get; set; } }" + ); + + return TestHelper.VerifyGenerator(source); + } + [Fact] public Task WithManualMappedNotFoundTargetPropertyShouldDiagnostic() { @@ -443,4 +456,33 @@ public void ShouldIgnoreStaticConstructorAndDiagnostic() .HaveDiagnostic(DiagnosticDescriptors.CouldNotCreateMapping) .HaveAssertedAllDiagnostics(); } + + [Fact] + public void ShouldFlattedNestedToTargetRootMapping() + { + var mapperBody = """ + [MapperNestedProperties(nameof(A.NestedValue)] + public partial C Map(A source); + """; + + var source = TestSourceBuilder.MapperWithBodyAndTypes( + mapperBody, + "class A { public B NestedValue { get; set; } public string DummyValue { get; set; } }", + "class B { public string GoodProp1 { get; set; } public string GoodProp2 { get; set; } }", + "class C { public string GoodProp1 { get; set; } public string GoodProp2 { get; set; } public string DummyValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::C(); + target.GoodProp1 = source.NestedValue.GoodProp1; + target.GoodProp2 = source.NestedValue.GoodProp2; + target.DummyValue = source.DummyValue; + return target; + """ + ); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithPropertyNameMappingStrategyCaseSensitiveAndManualMappedProperty#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithPropertyNameMappingStrategyCaseSensitiveAndManualMappedProperty#Mapper.g.verified.cs new file mode 100644 index 0000000000..c7886a8a20 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithPropertyNameMappingStrategyCaseSensitiveAndManualMappedProperty#Mapper.g.verified.cs @@ -0,0 +1,12 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + private partial global::B Map(global::A source) + { + var target = new global::B(); + target.StringValue2 = source.stringvalue; + return target; + } +}