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