Skip to content

Commit

Permalink
feat: Include all members of nested member
Browse files Browse the repository at this point in the history
Fixes riok#453
  • Loading branch information
Rhodon authored and latonz committed Apr 29, 2024
1 parent e8363bf commit b983831
Show file tree
Hide file tree
Showing 25 changed files with 530 additions and 43 deletions.
42 changes: 42 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapNestedPropertiesAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Diagnostics;

namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Maps all properties from a nested path on the source to the root of the target.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapNestedPropertiesAttribute : Attribute
{
private const string PropertyAccessSeparatorStr = ".";
private const char PropertyAccessSeparator = '.';

/// <summary>
/// Maps all members of the specified source property to the root of the target.
/// </summary>
/// <param name="source">
/// 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 '.'.
/// </param>
public MapNestedPropertiesAttribute(string source)
: this(source.Split(PropertyAccessSeparator)) { }

/// <summary>
/// Maps all members of the specified source property to the root of the target.
/// </summary>
/// <param name="source">The path of the source property that will be flattened. The use of `nameof()` is encouraged.</param>
public MapNestedPropertiesAttribute(string[] source)
{
Source = source;
}

/// <summary>
/// Gets the name of the source property to flatten.
/// </summary>
public IReadOnlyCollection<string> Source { get; }

/// <summary>
/// Gets the full name of the source property path to flatten.
/// </summary>
public string SourceFullName => string.Join(PropertyAccessSeparatorStr, Source);
}
5 changes: 5 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<string!>!
Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.SourceFullName.get -> string!
2 changes: 2 additions & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors;
Expand Down Expand Up @@ -44,7 +45,9 @@ public IEnumerable<TAttribute> Access<TAttribute>(ISymbol symbol)
/// <typeparam name="TData">The type of the data class. If no type parameters are involved, this is usually the same as <see cref="TAttribute"/>.</typeparam>
/// <returns>The attribute data.</returns>
/// <exception cref="InvalidOperationException">If a property or ctor argument of <see cref="TData"/> could not be read on the attribute.</exception>
public IEnumerable<TData> Access<TAttribute, TData>(ISymbol symbol)
public IEnumerable<TData> Access<TAttribute, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TData>(
ISymbol symbol
)
where TAttribute : Attribute
where TData : notnull
{
Expand Down
5 changes: 5 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ MapperConfiguration defaultMapperConfiguration
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<MemberMappingConfiguration>(),
Array.Empty<NestedMembersMappingConfiguration>(),
mapper.IgnoreObsoleteMembersStrategy,
mapper.RequiredMappingStrategy
),
Expand Down Expand Up @@ -82,6 +83,9 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
.WhereNotNull()
.ToList();
var memberConfigurations = _dataAccessor.Access<MapPropertyAttribute, MemberMappingConfiguration>(configRef.Method).ToList();
var nestedMembersConfigurations = _dataAccessor
.Access<MapNestedPropertiesAttribute, NestedMembersMappingConfiguration>(configRef.Method)
.ToList();
var ignoreObsolete = _dataAccessor
.AccessFirstOrDefault<MapperIgnoreObsoleteMembersAttribute>(configRef.Method)
?.IgnoreObsoleteStrategy;
Expand Down Expand Up @@ -115,6 +119,7 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
ignoredSourceMembers,
ignoredTargetMembers,
memberConfigurations,
nestedMembersConfigurations,
ignoreObsolete ?? MapperConfiguration.Members.IgnoreObsoleteMembersStrategy,
requiredMapping ?? MapperConfiguration.Members.RequiredMappingStrategy
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public record MembersMappingConfiguration(
IReadOnlyCollection<string> IgnoredSources,
IReadOnlyCollection<string> IgnoredTargets,
IReadOnlyCollection<MemberMappingConfiguration> ExplicitMappings,
IReadOnlyCollection<NestedMembersMappingConfiguration> NestedMappings,
IgnoreObsoleteMembersStrategy IgnoreObsoleteMembersStrategy,
RequiredMappingStrategy RequiredMappingStrategy
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Riok.Mapperly.Configuration;

public record NestedMembersMappingConfiguration(StringMemberPath Source);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.Mappings;
Expand Down Expand Up @@ -25,5 +26,14 @@ public interface IMembersBuilderContext<out T>

void AddDiagnostics();

/// <summary>
/// Tries to find a (possibly nested) MemberPath on the source type that can be mapped to <paramref name="targetMemberName"/>.
/// </summary>
bool TryFindNestedSourceMembersPath(
string targetMemberName,
[NotNullWhen(true)] out MemberPath? sourceMemberPath,
bool? ignoreCase = null
);

NullMemberMapping BuildNullMemberMapping(MemberPath sourcePath, INewInstanceMapping delegateMapping, ITypeSymbol targetMemberType);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;
Expand All @@ -17,10 +18,12 @@ public abstract class MembersMappingBuilderContext<T> : IMembersBuilderContext<T
where T : IMapping
{
private readonly HashSet<string> _unmappedSourceMemberNames;
private readonly HashSet<string> _unusedNestedMemberPaths;
private readonly HashSet<string> _mappedAndIgnoredTargetMemberNames;
private readonly HashSet<string> _mappedAndIgnoredSourceMemberNames;
private readonly IReadOnlyCollection<string> _ignoredUnmatchedTargetMemberNames;
private readonly IReadOnlyCollection<string> _ignoredUnmatchedSourceMemberNames;
private readonly IReadOnlyCollection<MemberPath> _nestedMemberPaths;

private bool _hasMemberMapping;

Expand All @@ -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();
Expand All @@ -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()))
Expand Down Expand Up @@ -101,6 +104,7 @@ public void AddDiagnostics()
AddUnmatchedIgnoredSourceMembersDiagnostics();
AddUnmatchedTargetMembersDiagnostics();
AddUnmatchedSourceMembersDiagnostics();
AddUnusedNestedMembersDiagnostics();
AddMappedAndIgnoredSourceMembersDiagnostics();
AddMappedAndIgnoredTargetMembersDiagnostics();
AddNoMemberMappedDiagnostic();
Expand All @@ -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<string>(),
ignoreCase.Value,
out var nestedSourceMemberPath
)
)
{
sourceMemberPath = new MemberPath(nestedMemberPath.Path.Concat(nestedSourceMemberPath.Path).ToList());
_unusedNestedMemberPaths.Remove(nestedMemberPath.FullName);
return true;
}
}

return false;
}

private HashSet<string> InitIgnoredUnmatchedProperties(IEnumerable<string> allProperties, IEnumerable<string> mappedProperties)
{
var unmatched = new HashSet<string>(allProperties);
Expand Down Expand Up @@ -180,6 +228,27 @@ private Dictionary<string, List<MemberMappingConfiguration>> GetMemberConfigurat
.ToDictionary(x => x.Key, x => x.ToList());
}

private IReadOnlyCollection<MemberPath> GetNestedMemberPaths()
{
var nestedMemberPaths = new List<MemberPath>();

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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ public static void BuildMappingBody(MappingBuilderContext ctx, NewInstanceObject

private static void BuildInitMemberMappings(INewInstanceBuilderContext<IMapping> 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();
Expand All @@ -49,15 +47,7 @@ private static void BuildInitMemberMappings(INewInstanceBuilderContext<IMapping>
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)
{
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,7 @@ public static void BuildMappingBody(IMembersContainerBuilderContext<IMemberAssig
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))
{
BuildMemberAssignmentMapping(ctx, sourceMemberPath, new MemberPath(new[] { targetMember }));
continue;
Expand Down
20 changes: 20 additions & 0 deletions src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,26 @@ public static class DiagnosticDescriptors
true
);

public static readonly DiagnosticDescriptor ConfiguredMappingNestedMemberNotFound =
new(
"RMG070",
"Mapping nested member not found",
"Specified nested member {0} on source type {1} was not found",
DiagnosticCategories.Mapper,
DiagnosticSeverity.Error,
true
);

public static readonly DiagnosticDescriptor NestedMemberNotUsed =
new(
"RMG071",
"Nested properties mapping is not used",
"Configured nested member {0} on source type {1} is not used",
DiagnosticCategories.Mapper,
DiagnosticSeverity.Warning,
true
);

private static string BuildHelpUri(string id)
{
#if ENV_NEXT
Expand Down
Loading

0 comments on commit b983831

Please sign in to comment.