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;
+ }
+}