diff --git a/src/Riok.Mapperly.Abstractions/MapNestedPropertiesAttribute.cs b/src/Riok.Mapperly.Abstractions/MapNestedPropertiesAttribute.cs
new file mode 100644
index 0000000000..861225bf23
--- /dev/null
+++ b/src/Riok.Mapperly.Abstractions/MapNestedPropertiesAttribute.cs
@@ -0,0 +1,42 @@
+using System.Diagnostics;
+
+namespace Riok.Mapperly.Abstractions;
+
+///
+/// Maps all properties from a nested path on the source to the root of the target.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
+public sealed class MapNestedPropertiesAttribute : Attribute
+{
+ private const string PropertyAccessSeparatorStr = ".";
+ private const char PropertyAccessSeparator = '.';
+
+ ///
+ /// Maps all members of the specified source property to the root of the target.
+ ///
+ ///
+ /// The name of the source property that will be flattened. The use of `nameof()` is encouraged. A path can be specified by joining property names with a '.'.
+ ///
+ public MapNestedPropertiesAttribute(string source)
+ : this(source.Split(PropertyAccessSeparator)) { }
+
+ ///
+ /// Maps all members of the specified source property to the root of the target.
+ ///
+ /// The path of the source property that will be flattened. The use of `nameof()` is encouraged.
+ public MapNestedPropertiesAttribute(string[] source)
+ {
+ Source = source;
+ }
+
+ ///
+ /// Gets the name of the source property to flatten.
+ ///
+ public IReadOnlyCollection Source { get; }
+
+ ///
+ /// Gets the full name of the source property path to flatten.
+ ///
+ 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 f1caebc197..57713d093a 100644
--- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
+++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
@@ -155,3 +155,8 @@ Riok.Mapperly.Abstractions.UserMappingAttribute.Default.get -> bool
Riok.Mapperly.Abstractions.UserMappingAttribute.Default.set -> void
Riok.Mapperly.Abstractions.MapPropertyAttribute.Use.get -> string?
Riok.Mapperly.Abstractions.MapPropertyAttribute.Use.set -> void
+Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute
+Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.MapNestedPropertiesAttribute(string! source) -> void
+Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.MapNestedPropertiesAttribute(string![]! source) -> void
+Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.Source.get -> System.Collections.Generic.IReadOnlyCollection!
+Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.SourceFullName.get -> string!
diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
index e52b60b3c9..5b7df6e2da 100644
--- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
+++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
@@ -154,3 +154,5 @@ RMG066 | Mapper | Warning | No members are mapped in an object mapping
RMG067 | Mapper | Error | Invalid usage of the MapPropertyAttribute
RMG068 | Mapper | Info | Cannot inline user implemented queryable expression mapping
RMG069 | Mapper | Warning | Runtime target type or generic type mapping does not match any mappings
+RMG070 | Mapper | Error | Mapping nested member not found
+RMG071 | Mapper | Warning | Nested properties mapping is not used
diff --git a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
index ca19ccc08e..3a58e1bd75 100644
--- a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
+++ b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors;
@@ -44,7 +45,9 @@ public IEnumerable Access(ISymbol symbol)
/// The type of the data class. If no type parameters are involved, this is usually the same as .
/// The attribute data.
/// If a property or ctor argument of could not be read on the attribute.
- public IEnumerable Access(ISymbol symbol)
+ public IEnumerable Access(
+ ISymbol symbol
+ )
where TAttribute : Attribute
where TData : notnull
{
diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
index 31596f4fbc..91f010ad3c 100644
--- a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
+++ b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
@@ -38,6 +38,7 @@ MapperConfiguration defaultMapperConfiguration
Array.Empty(),
Array.Empty(),
Array.Empty(),
+ Array.Empty(),
mapper.IgnoreObsoleteMembersStrategy,
mapper.RequiredMappingStrategy
),
@@ -82,6 +83,9 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
.WhereNotNull()
.ToList();
var memberConfigurations = _dataAccessor.Access(configRef.Method).ToList();
+ var nestedMembersConfigurations = _dataAccessor
+ .Access(configRef.Method)
+ .ToList();
var ignoreObsolete = _dataAccessor
.AccessFirstOrDefault(configRef.Method)
?.IgnoreObsoleteStrategy;
@@ -115,6 +119,7 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
ignoredSourceMembers,
ignoredTargetMembers,
memberConfigurations,
+ nestedMembersConfigurations,
ignoreObsolete ?? MapperConfiguration.Members.IgnoreObsoleteMembersStrategy,
requiredMapping ?? MapperConfiguration.Members.RequiredMappingStrategy
);
diff --git a/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs
index 0f8f9d9dc7..e9b9fb9c62 100644
--- a/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs
+++ b/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs
@@ -6,6 +6,7 @@ public record MembersMappingConfiguration(
IReadOnlyCollection IgnoredSources,
IReadOnlyCollection IgnoredTargets,
IReadOnlyCollection ExplicitMappings,
+ IReadOnlyCollection NestedMappings,
IgnoreObsoleteMembersStrategy IgnoreObsoleteMembersStrategy,
RequiredMappingStrategy RequiredMappingStrategy
);
diff --git a/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs
new file mode 100644
index 0000000000..98f8eea473
--- /dev/null
+++ b/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs
@@ -0,0 +1,3 @@
+namespace Riok.Mapperly.Configuration;
+
+public record NestedMembersMappingConfiguration(StringMemberPath Source);
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IMembersBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IMembersBuilderContext.cs
index 224366578b..e3d7ce783a 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IMembersBuilderContext.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IMembersBuilderContext.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.Mappings;
@@ -25,5 +26,14 @@ public interface IMembersBuilderContext
void AddDiagnostics();
+ ///
+ /// Tries to find a (possibly nested) MemberPath on the source type that can be mapped to .
+ ///
+ bool TryFindNestedSourceMembersPath(
+ string targetMemberName,
+ [NotNullWhen(true)] out MemberPath? sourceMemberPath,
+ bool? ignoreCase = null
+ );
+
NullMemberMapping BuildNullMemberMapping(MemberPath sourcePath, INewInstanceMapping delegateMapping, ITypeSymbol targetMemberType);
}
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs
index b05e50da51..580bd303a8 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;
@@ -17,10 +18,12 @@ public abstract class MembersMappingBuilderContext : IMembersBuilderContext _unmappedSourceMemberNames;
+ private readonly HashSet _unusedNestedMemberPaths;
private readonly HashSet _mappedAndIgnoredTargetMemberNames;
private readonly HashSet _mappedAndIgnoredSourceMemberNames;
private readonly IReadOnlyCollection _ignoredUnmatchedTargetMemberNames;
private readonly IReadOnlyCollection _ignoredUnmatchedSourceMemberNames;
+ private readonly IReadOnlyCollection _nestedMemberPaths;
private bool _hasMemberMapping;
@@ -29,6 +32,8 @@ protected MembersMappingBuilderContext(MappingBuilderContext builderContext, T m
BuilderContext = builderContext;
Mapping = mapping;
MemberConfigsByRootTargetName = GetMemberConfigurations();
+ _nestedMemberPaths = GetNestedMemberPaths();
+ _unusedNestedMemberPaths = _nestedMemberPaths.Select(c => c.FullName).ToHashSet();
_unmappedSourceMemberNames = GetSourceMemberNames();
TargetMembers = GetTargetMembers();
@@ -50,8 +55,6 @@ protected MembersMappingBuilderContext(MappingBuilderContext builderContext, T m
_unmappedSourceMemberNames.ExceptWith(IgnoredSourceMemberNames);
- MemberConfigsByRootTargetName = GetMemberConfigurations();
-
// source and target properties may have been ignored and mapped explicitly
_mappedAndIgnoredSourceMemberNames = MemberConfigsByRootTargetName
.Values.SelectMany(v => v.Select(s => s.Source.Path.First()))
@@ -101,6 +104,7 @@ public void AddDiagnostics()
AddUnmatchedIgnoredSourceMembersDiagnostics();
AddUnmatchedTargetMembersDiagnostics();
AddUnmatchedSourceMembersDiagnostics();
+ AddUnusedNestedMembersDiagnostics();
AddMappedAndIgnoredSourceMembersDiagnostics();
AddMappedAndIgnoredTargetMembersDiagnostics();
AddNoMemberMappedDiagnostic();
@@ -112,6 +116,50 @@ protected void SetSourceMemberMapped(MemberPath sourcePath)
_unmappedSourceMemberNames.Remove(sourcePath.Path.First().Name);
}
+ public bool TryFindNestedSourceMembersPath(
+ string targetMemberName,
+ [NotNullWhen(true)] out MemberPath? sourceMemberPath,
+ bool? ignoreCase = null
+ )
+ {
+ ignoreCase ??= BuilderContext.Configuration.Mapper.PropertyNameMappingStrategy == PropertyNameMappingStrategy.CaseInsensitive;
+ var pathCandidates = MemberPathCandidateBuilder.BuildMemberPathCandidates(targetMemberName).Select(cs => cs.ToList()).ToList();
+
+ // First, try to find the property on (a sub-path of) the source type itself. (If this is undesired, an Ignore property can be used.)
+ if (
+ BuilderContext.SymbolAccessor.TryFindMemberPath(
+ Mapping.SourceType,
+ pathCandidates,
+ IgnoredSourceMemberNames,
+ ignoreCase.Value,
+ out sourceMemberPath
+ )
+ )
+ return true;
+
+ // Otherwise, search all nested members
+ foreach (var nestedMemberPath in _nestedMemberPaths)
+ {
+ if (
+ BuilderContext.SymbolAccessor.TryFindMemberPath(
+ nestedMemberPath.MemberType,
+ pathCandidates,
+ // Use empty ignore list to support ignoring a property for normal search while flattening its properties
+ Array.Empty(),
+ ignoreCase.Value,
+ out var nestedSourceMemberPath
+ )
+ )
+ {
+ sourceMemberPath = new MemberPath(nestedMemberPath.Path.Concat(nestedSourceMemberPath.Path).ToList());
+ _unusedNestedMemberPaths.Remove(nestedMemberPath.FullName);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private HashSet InitIgnoredUnmatchedProperties(IEnumerable allProperties, IEnumerable mappedProperties)
{
var unmatched = new HashSet(allProperties);
@@ -180,6 +228,27 @@ private Dictionary> GetMemberConfigurat
.ToDictionary(x => x.Key, x => x.ToList());
}
+ private IReadOnlyCollection GetNestedMemberPaths()
+ {
+ var nestedMemberPaths = new List();
+
+ foreach (var nestedMemberConfig in BuilderContext.Configuration.Members.NestedMappings)
+ {
+ if (!BuilderContext.SymbolAccessor.TryFindMemberPath(Mapping.SourceType, nestedMemberConfig.Source.Path, out var memberPath))
+ {
+ BuilderContext.ReportDiagnostic(
+ DiagnosticDescriptors.ConfiguredMappingNestedMemberNotFound,
+ nestedMemberConfig.Source.FullName,
+ Mapping.SourceType
+ );
+ continue;
+ }
+ nestedMemberPaths.Add(memberPath);
+ }
+
+ return nestedMemberPaths;
+ }
+
private void AddUnmatchedIgnoredTargetMembersDiagnostics()
{
foreach (var notFoundIgnoredMember in _ignoredUnmatchedTargetMemberNames)
@@ -234,6 +303,14 @@ private void AddUnmatchedSourceMembersDiagnostics()
}
}
+ private void AddUnusedNestedMembersDiagnostics()
+ {
+ foreach (var sourceMemberPath in _unusedNestedMemberPaths)
+ {
+ BuilderContext.ReportDiagnostic(DiagnosticDescriptors.NestedMemberNotUsed, sourceMemberPath, Mapping.SourceType);
+ }
+ }
+
private void AddMappedAndIgnoredTargetMembersDiagnostics()
{
foreach (var targetMemberName in _mappedAndIgnoredTargetMemberNames)
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs
index d275671a33..2a84329c1c 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs
@@ -34,8 +34,6 @@ public static void BuildMappingBody(MappingBuilderContext ctx, NewInstanceObject
private static void BuildInitMemberMappings(INewInstanceBuilderContext ctx, bool includeAllMembers = false)
{
- var ignoreCase = ctx.BuilderContext.Configuration.Mapper.PropertyNameMappingStrategy == PropertyNameMappingStrategy.CaseInsensitive;
-
var initOnlyTargetMembers = includeAllMembers
? ctx.TargetMembers.Values.ToArray()
: ctx.TargetMembers.Values.Where(x => x.CanOnlySetViaInitializer()).ToArray();
@@ -49,15 +47,7 @@ private static void BuildInitMemberMappings(INewInstanceBuilderContext
continue;
}
- if (
- !ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(
- ctx.Mapping.SourceType,
- MemberPathCandidateBuilder.BuildMemberPathCandidates(targetMember.Name),
- ctx.IgnoredSourceMemberNames,
- ignoreCase,
- out var sourceMemberPath
- )
- )
+ if (!ctx.TryFindNestedSourceMembersPath(targetMember.Name, out var sourceMemberPath))
{
if (targetMember.IsRequired)
{
@@ -308,13 +298,7 @@ out MemberMappingConfiguration? memberConfig
|| !ctx.MemberConfigsByRootTargetName.TryGetValue(parameterName, out var memberConfigs)
)
{
- return ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(
- ctx.Mapping.SourceType,
- MemberPathCandidateBuilder.BuildMemberPathCandidates(parameter.Name),
- ctx.IgnoredSourceMemberNames,
- true,
- out sourcePath
- );
+ return ctx.TryFindNestedSourceMembersPath(parameter.Name, out sourcePath, true);
}
if (memberConfigs.Count > 1)
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs
index 5c308f4e7b..37e5477468 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs
@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
-using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;
using Riok.Mapperly.Descriptors.Mappings;
@@ -170,17 +169,7 @@ private static bool TryBuildConstructorParameterSourcePath(
out MemberPath? sourcePath
)
{
- var ignoreCase = ctx.BuilderContext.Configuration.Mapper.PropertyNameMappingStrategy == PropertyNameMappingStrategy.CaseInsensitive;
-
- if (
- ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(
- ctx.Mapping.SourceType,
- MemberPathCandidateBuilder.BuildMemberPathCandidates(field.Name),
- ctx.IgnoredSourceMemberNames,
- ignoreCase,
- out sourcePath
- )
- )
+ if (ctx.TryFindNestedSourceMembersPath(field.Name, out sourcePath))
{
return true;
}
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs
index e9e4b60f26..2c2b229782 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs
@@ -40,15 +40,7 @@ public static void BuildMappingBody(IMembersContainerBuilderContext(new[] { "1", "2", "3" }),
QueueValue = new Queue(new[] { "1", "2", "3" }),
diff --git a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs
index a92c8d2a07..71bbe1d1f9 100644
--- a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs
+++ b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs
@@ -47,6 +47,10 @@ public TestObjectDto(int ctorValue, int unknownValue = 10, int ctorValue2 = 100)
public TestObjectNestedDto NestedNullableTargetNotNullable { get; set; } = new();
+ public int ExtensionId { get; set; }
+
+ public int ExtensionNestedIntValue { get; set; }
+
public string StringNullableTargetNotNullable { get; set; } = string.Empty;
public (int A, int)? TupleValue { get; set; }
diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs
index 63fabfe8a8..75f112b645 100644
--- a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs
+++ b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs
@@ -76,6 +76,7 @@ public TestObjectDto MapToDto(TestObject src)
nameof(TestObject.NullableUnflatteningIdValue),
nameof(TestObjectDto.NullableUnflattening) + "." + nameof(TestObjectDto.NullableUnflattening.IdValue)
)]
+ [MapNestedProperties(nameof(TestObject.Extension))]
[MapperIgnoreObsoleteMembers]
private partial TestObjectDto MapToDtoInternal(TestObject testObject);
diff --git a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs
index 0c0af0ea83..102ccc2ba2 100644
--- a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs
+++ b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs
@@ -45,6 +45,8 @@ public TestObject(int ctorValue, int unknownValue = 10, int ctorValue2 = 100)
public TestObjectNested? NestedNullableTargetNotNullable { get; set; }
+ public TestObjectExtension? Extension { get; set; }
+
public string? StringNullableTargetNotNullable { get; set; }
public (string A, string)? TupleValue { get; set; }
diff --git a/test/Riok.Mapperly.IntegrationTests/Models/TestObjectExtension.cs b/test/Riok.Mapperly.IntegrationTests/Models/TestObjectExtension.cs
new file mode 100644
index 0000000000..bf3f6f51c8
--- /dev/null
+++ b/test/Riok.Mapperly.IntegrationTests/Models/TestObjectExtension.cs
@@ -0,0 +1,8 @@
+namespace Riok.Mapperly.IntegrationTests.Models
+{
+ public class TestObjectExtension
+ {
+ public int ExtensionId { get; set; }
+ public TestObjectNested? ExtensionNested { get; set; }
+ }
+}
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt
index d3bf52de8e..0a1d454f5d 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt
@@ -19,6 +19,12 @@
IntValue: 100
},
NestedNullableTargetNotNullable: {},
+ Extension: {
+ ExtensionId: 12,
+ ExtensionNested: {
+ IntValue: 22
+ }
+ },
StringNullableTargetNotNullable: fooBar3,
TupleValue: {
Item1: 10,
@@ -52,6 +58,12 @@
StringValue: ,
RenamedStringValue: ,
Flattening: {},
+ Extension: {
+ ExtensionId: 123,
+ ExtensionNested: {
+ IntValue: 223
+ }
+ },
MemoryValue: {
IsEmpty: true
},
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs
index b54960d149..13823a86a6 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs
@@ -44,6 +44,14 @@ public static partial class DeepCloningMapper
{
target.NestedNullableTargetNotNullable = null;
}
+ if (src.Extension != null)
+ {
+ target.Extension = MapToTestObjectExtension(src.Extension);
+ }
+ else
+ {
+ target.Extension = null;
+ }
if (src.TupleValue != null)
{
target.TupleValue = MapToValueTupleOfStringAndString(src.TupleValue.Value);
@@ -143,6 +151,22 @@ public static partial class DeepCloningMapper
return target;
}
+ [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")]
+ private static global::Riok.Mapperly.IntegrationTests.Models.TestObjectExtension MapToTestObjectExtension(global::Riok.Mapperly.IntegrationTests.Models.TestObjectExtension source)
+ {
+ var target = new global::Riok.Mapperly.IntegrationTests.Models.TestObjectExtension();
+ if (source.ExtensionNested != null)
+ {
+ target.ExtensionNested = MapToTestObjectNested(source.ExtensionNested);
+ }
+ else
+ {
+ target.ExtensionNested = null;
+ }
+ target.ExtensionId = source.ExtensionId;
+ return target;
+ }
+
[global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")]
private static (string A, string) MapToValueTupleOfStringAndString((string A, string) source)
{
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET8_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET8_0.verified.txt
index 1adfb8a39b..b49a5c1416 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET8_0.verified.txt
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET8_0.verified.txt
@@ -19,6 +19,8 @@
IntValue: 100
},
NestedNullableTargetNotNullable: {},
+ ExtensionId: 12,
+ ExtensionNestedIntValue: 22,
StringNullableTargetNotNullable: fooBar3,
TupleValue: {
Item1: 10,
@@ -61,6 +63,12 @@
StringValue: ,
RenamedStringValue: ,
Flattening: {},
+ Extension: {
+ ExtensionId: 123,
+ ExtensionNested: {
+ IntValue: 223
+ }
+ },
ImmutableArrayValue: null,
ImmutableQueueValue: [],
ImmutableStackValue: [],
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs
index 14809d31a1..d91556e538 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs
@@ -86,6 +86,14 @@ public partial int ParseableInt(string value)
{
target.NestedNullableTargetNotNullable = MapToTestObjectNestedDto(testObject.NestedNullableTargetNotNullable);
}
+ if (testObject.Extension != null)
+ {
+ if (testObject.Extension.ExtensionNested != null)
+ {
+ target.ExtensionNestedIntValue = DirectInt(testObject.Extension.ExtensionNested.IntValue);
+ }
+ target.ExtensionId = DirectInt(testObject.Extension.ExtensionId);
+ }
if (testObject.StringNullableTargetNotNullable != null)
{
target.StringNullableTargetNotNullable = testObject.StringNullableTargetNotNullable;
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt
index 805c4557bb..bce7d7c58a 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt
@@ -56,6 +56,12 @@
StringValue: ,
RenamedStringValue: ,
Flattening: {},
+ Extension: {
+ ExtensionId: 123,
+ ExtensionNested: {
+ IntValue: 223
+ }
+ },
ImmutableArrayValue: null,
ImmutableQueueValue: [],
ImmutableStackValue: [],
diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt
index 0c6249cf99..b22767f124 100644
--- a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt
+++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt
@@ -61,6 +61,12 @@
StringValue: ,
RenamedStringValue: ,
Flattening: {},
+ Extension: {
+ ExtensionId: 123,
+ ExtensionNested: {
+ IntValue: 223
+ }
+ },
ImmutableArrayValue: null,
ImmutableQueueValue: [],
ImmutableStackValue: [],
diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectNestedPropertyTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectNestedPropertyTest.cs
new file mode 100644
index 0000000000..5388d60cc1
--- /dev/null
+++ b/test/Riok.Mapperly.Tests/Mapping/ObjectNestedPropertyTest.cs
@@ -0,0 +1,261 @@
+using Microsoft.CodeAnalysis;
+using Riok.Mapperly.Diagnostics;
+
+namespace Riok.Mapperly.Tests.Mapping;
+
+public class ObjectNestedPropertyTest
+{
+ private static readonly TestHelperOptions ignoreNestedMemberNotUsed =
+ new() { IgnoredDiagnostics = new HashSet { DiagnosticDescriptors.NestedMemberNotUsed } };
+
+ [Fact]
+ public void NestedPropertyWithMemberNameOfSource()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Value.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void DeeplyNestedPropertyWithMemberNameOfSource()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(@A.Value.NestedValue))] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public D NestedValue { get; set; } }",
+ "class D { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Value.NestedValue.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void DeeplyNestedPropertyAsArrayWithMemberNameOfSource()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(new [] {nameof(A.Value), nameof(C.NestedValue)})] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public D NestedValue { get; set; } }",
+ "class D { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Value.NestedValue.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void RootPropertiesShouldBePreferredOverNestedProperties()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public string Id { get; set; } public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source, ignoreNestedMemberNotUsed)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void NestedPropertyWithSourcePathName()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public string ValueId { get; set; } }",
+ "class C { public string ValueId { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.ValueId = source.Value.ValueId;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void NestedPropertyWithSourcePathNamePrefersAutoFlattenedCompletePath()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public string ValueId { get; set; } }",
+ "class C { public string Id { get; set; } public string ValueId { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source, ignoreNestedMemberNotUsed)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.ValueId = source.Value.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void UnusedNestedPropertiesShouldDiagnostic()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public string Id { get; set; } public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public string MyId { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
+ .Should()
+ .HaveDiagnostic(DiagnosticDescriptors.NestedMemberNotUsed)
+ .HaveDiagnostic(DiagnosticDescriptors.SourceMemberNotMapped)
+ .HaveAssertedAllDiagnostics()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void UnusedNestedPropertiesShouldDiagnosticEvenIfPropertyIsUsed()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(nameof(A.Value))] partial B Map(A source);",
+ "class A { public C Value { get; set; } }",
+ "class B { public C Value { get; set; } }",
+ "class C { public string MyId { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
+ .Should()
+ .HaveDiagnostic(DiagnosticDescriptors.NestedMemberNotUsed)
+ .HaveAssertedAllDiagnostics()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Value = source.Value;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void InvalidNestedPropertiesPathShouldDiagnostic()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ "[MapNestedProperties(\"Value\")] partial B Map(A source);",
+ "class A { public string Id { get; set; } }",
+ "class B { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
+ .Should()
+ .HaveDiagnostic(DiagnosticDescriptors.ConfiguredMappingNestedMemberNotFound)
+ .HaveAssertedAllDiagnostics();
+ }
+
+ [Fact]
+ public void IgnoredNestedPropertyShouldNotDiagnostic()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ """
+ [MapperIgnoreSource(nameof(A.Value))]
+ [MapNestedProperties(nameof(A.Value))]
+ partial B Map(A source);
+ """,
+ "class A { public C Value { get; set; } }",
+ "class B { public string Id { get; set; } }",
+ "class C { public string Id { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.Id = source.Value.Id;
+ return target;
+ """
+ );
+ }
+
+ [Fact]
+ public void IgnoredNestedPropertyShouldBePreferredOverAutoFlattened()
+ {
+ var source = TestSourceBuilder.MapperWithBodyAndTypes(
+ """
+ [MapperIgnoreSource(nameof(A.Value))]
+ [MapNestedProperties(nameof(A.Value))]
+ partial B Map(A source);
+ """,
+ "class A { public C Value { get; set; } }",
+ "class B { public string ValueId { get; set; } }",
+ "class C { public string Id { get; set; } public string ValueId { get; set; } }"
+ );
+
+ TestHelper
+ .GenerateMapper(source)
+ .Should()
+ .HaveSingleMethodBody(
+ """
+ var target = new global::B();
+ target.ValueId = source.Value.ValueId;
+ return target;
+ """
+ );
+ }
+}