From 0ed075462c7be3abe3ca610cda5cd67f7f595300 Mon Sep 17 00:00:00 2001 From: latonz Date: Sun, 6 Oct 2024 20:12:36 +0200 Subject: [PATCH] improve fullnameof and allow namespaced and nested types --- docs/docs/configuration/flattening.md | 15 +-- .../Configuration/AttributeDataAccessor.cs | 93 +++++++++++++------ .../Configuration/IMemberPathConfiguration.cs | 22 +++++ .../MemberMappingConfiguration.cs | 4 +- .../Configuration/MemberPathConstants.cs | 7 ++ .../MemberValueMappingConfiguration.cs | 4 +- .../MembersMappingConfiguration.cs | 6 +- .../NestedMembersMappingConfiguration.cs | 2 +- .../Configuration/StringMemberPath.cs | 9 +- .../Configuration/SymbolMemberPath.cs | 21 +++++ .../BuilderContext/IgnoredMembersBuilder.cs | 2 +- .../MembersMappingBuilderContext.cs | 8 +- .../BuilderContext/MembersMappingState.cs | 6 +- .../MembersMappingStateBuilder.cs | 16 ++-- .../SimpleMappingBuilderContext.cs | 18 +--- .../Descriptors/SymbolAccessor.cs | 36 +++++++ .../Helpers/OperationExtensions.cs | 15 +++ .../Symbols/CompilationContext.cs | 21 ++++- .../Members/ConstructorParameterMember.cs | 1 + .../Symbols/Members/FieldMember.cs | 1 + .../Symbols/Members/IMappableMember.cs | 2 + .../Symbols/Members/ParameterSourceMember.cs | 1 + .../Symbols/Members/PropertyMember.cs | 3 + .../Mapping/ObjectPropertyFlatteningTest.cs | 73 ++++++++++++++- 24 files changed, 299 insertions(+), 87 deletions(-) create mode 100644 src/Riok.Mapperly/Configuration/IMemberPathConfiguration.cs create mode 100644 src/Riok.Mapperly/Configuration/MemberPathConstants.cs create mode 100644 src/Riok.Mapperly/Configuration/SymbolMemberPath.cs create mode 100644 src/Riok.Mapperly/Helpers/OperationExtensions.cs diff --git a/docs/docs/configuration/flattening.md b/docs/docs/configuration/flattening.md index f6c676bd34..f36b3db92d 100644 --- a/docs/docs/configuration/flattening.md +++ b/docs/docs/configuration/flattening.md @@ -6,7 +6,7 @@ description: Flatten properties and fields # Flattening and unflattening It is pretty common to flatten objects during mapping, eg. `Car.Make.Id => CarDto.MakeId`. -Mapperly tries to figure out flattenings automatically by making use of the pascal case C# notation. +Mapperly tries to figure out flattenings automatically by making use of the PascalCase C# notation. If Mapperly can't resolve the target or source property correctly, it is possible to manually configure it by applying the `MapPropertyAttribute` by either using the source and target property path names as arrays or using a dot separated property access path string @@ -52,9 +52,9 @@ If multiple `MapNestedProperties` are defined that contain members that match to In such a case it is therefore recommended to define the expected property mapping explicitly using a `MapProperty` attribute. ::: -## Experimental full `nameof` +## Full `nameof` -Mapperly supports an experimental "fullnameof". +Mapperly supports a "fullnameof". It can be used to configure property paths using `nameof`. Opt-in is done by prefixing the path with `@`. @@ -63,11 +63,4 @@ Opt-in is done by prefixing the path with `@`. partial CarDto Map(Car car); ``` -`@nameof(Car.Make.Id)` will result in the property path `Make.Id`. -The first part of the property path is stripped. -Make sure these property paths start with the type of the property and not with a namespace or a property. - -:::warning -This is an experimental API. -Its API surface is not subject to semantic releases and may break in any release. -::: +`nameof(@Car.Make.Id)` will result in the property path `Make.Id`. diff --git a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs index 5b0f96cb80..2f1b00322d 100644 --- a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs +++ b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs @@ -1,6 +1,7 @@ using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; using Riok.Mapperly.Descriptors; using Riok.Mapperly.Helpers; @@ -52,11 +53,11 @@ public IEnumerable Access(ISymbol symbol) var attrDatas = symbolAccessor.GetAttributes(symbol); foreach (var attrData in attrDatas) { - yield return Access(attrData); + yield return Access(attrData, symbolAccessor); } } - internal static TData Access(AttributeData attrData) + internal static TData Access(AttributeData attrData, SymbolAccessor? symbolAccessor = null) where TAttribute : Attribute where TData : notnull { @@ -68,7 +69,7 @@ internal static TData Access(AttributeData attrData) (IReadOnlyList?)syntax?.ArgumentList?.Arguments ?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length]; var typeArguments = (IReadOnlyCollection?)attrData.AttributeClass?.TypeArguments ?? []; - var attr = Create(typeArguments, attrData.ConstructorArguments, syntaxArguments); + var attr = Create(typeArguments, attrData.ConstructorArguments, syntaxArguments, symbolAccessor); var syntaxIndex = attrData.ConstructorArguments.Length; var propertiesByName = dataType.GetProperties().GroupBy(x => x.Name).ToDictionary(x => x.Key, x => x.First()); @@ -77,7 +78,7 @@ internal static TData Access(AttributeData attrData) if (!propertiesByName.TryGetValue(namedArgument.Key, out var prop)) throw new InvalidOperationException($"Could not get property {namedArgument.Key} of attribute {attrType.FullName}"); - var value = BuildArgumentValue(namedArgument.Value, prop.PropertyType, syntaxArguments[syntaxIndex]); + var value = BuildArgumentValue(namedArgument.Value, prop.PropertyType, syntaxArguments[syntaxIndex], symbolAccessor); prop.SetValue(attr, value); syntaxIndex++; } @@ -93,7 +94,8 @@ internal static TData Access(AttributeData attrData) private static TData Create( IReadOnlyCollection typeArguments, IReadOnlyCollection constructorArguments, - IReadOnlyList argumentSyntax + IReadOnlyList argumentSyntax, + SymbolAccessor? symbolAccessor ) where TData : notnull { @@ -110,7 +112,7 @@ IReadOnlyList argumentSyntax continue; var constructorArgumentValues = constructorArguments.Select( - (arg, i) => BuildArgumentValue(arg, parameters[i + typeArguments.Count].ParameterType, argumentSyntax[i]) + (arg, i) => BuildArgumentValue(arg, parameters[i + typeArguments.Count].ParameterType, argumentSyntax[i], symbolAccessor) ); var constructorTypeAndValueArguments = typeArguments.Concat(constructorArgumentValues).ToArray(); if (!ValidateParameterTypes(constructorTypeAndValueArguments, parameters)) @@ -125,7 +127,12 @@ IReadOnlyList argumentSyntax ); } - private static object? BuildArgumentValue(TypedConstant arg, Type targetType, AttributeArgumentSyntax? syntax) + private static object? BuildArgumentValue( + TypedConstant arg, + Type targetType, + AttributeArgumentSyntax? syntax, + SymbolAccessor? symbolAccessor + ) { return arg.Kind switch { @@ -134,9 +141,9 @@ IReadOnlyList argumentSyntax syntax.Expression ), _ when arg.IsNull => null, - _ when targetType == typeof(StringMemberPath) => CreateMemberPath(arg, syntax), + _ when targetType == typeof(IMemberPathConfiguration) => CreateMemberPath(arg, syntax, symbolAccessor), TypedConstantKind.Enum => GetEnumValue(arg, targetType), - TypedConstantKind.Array => BuildArrayValue(arg, targetType), + TypedConstantKind.Array => BuildArrayValue(arg, targetType, symbolAccessor), TypedConstantKind.Primitive => arg.Value, TypedConstantKind.Type when targetType == typeof(ITypeSymbol) => arg.Value, _ => throw new ArgumentOutOfRangeException( @@ -145,12 +152,21 @@ IReadOnlyList argumentSyntax }; } - private static StringMemberPath CreateMemberPath(TypedConstant arg, AttributeArgumentSyntax? syntax) + private static IMemberPathConfiguration CreateMemberPath( + TypedConstant arg, + AttributeArgumentSyntax? syntax, + SymbolAccessor? symbolAccessor + ) { + if (symbolAccessor == null) + { + throw new ArgumentNullException(nameof(symbolAccessor), "The symbol accessor cannot be null when resolving member paths"); + } + if (arg.Kind == TypedConstantKind.Array) { var values = arg - .Values.Select(x => (string?)BuildArgumentValue(x, typeof(string), null)) + .Values.Select(x => (string?)BuildArgumentValue(x, typeof(string), null, symbolAccessor)) .WhereNotNull() .ToImmutableEquatableArray(); return new StringMemberPath(values); @@ -166,35 +182,60 @@ is InvocationExpressionSyntax } invocationExpressionSyntax ) { - var argMemberPathStr = invocationExpressionSyntax.ArgumentList.Arguments[0].ToFullString(); - - // @ prefix opts-in to full nameof - if (argMemberPathStr.Length > 0 && argMemberPathStr[0] == FullNameOfPrefix) - { - var argMemberPath = argMemberPathStr - .TrimStart(FullNameOfPrefix) - .Split(StringMemberPath.MemberAccessSeparator) - .Skip(1) - .ToImmutableEquatableArray(); - return new StringMemberPath(argMemberPath); - } + return CreateNameOfMemberPath(invocationExpressionSyntax, symbolAccessor); } if (arg is { Kind: TypedConstantKind.Primitive, Value: string v }) { - return new StringMemberPath(v.Split(StringMemberPath.MemberAccessSeparator).ToImmutableEquatableArray()); + return new StringMemberPath(v.Split(MemberPathConstants.MemberAccessSeparator).ToImmutableEquatableArray()); } throw new InvalidOperationException($"Cannot create {nameof(StringMemberPath)} from {arg.Kind}"); } - private static object?[] BuildArrayValue(TypedConstant arg, Type targetType) + private static IMemberPathConfiguration CreateNameOfMemberPath(InvocationExpressionSyntax nameofSyntax, SymbolAccessor symbolAccessor) + { + var argMemberPathStr = nameofSyntax.ArgumentList.Arguments[0].ToFullString(); + + // @ prefix opts-in to full nameof + var fullNameOf = argMemberPathStr[0] == FullNameOfPrefix; + + var nameOfOperation = symbolAccessor.GetOperation(nameofSyntax) as INameOfOperation; + var memberRefOperation = nameOfOperation?.GetFirstChildOperation() as IMemberReferenceOperation; + if (memberRefOperation == null) + { + // fall back to old skip-first-segment approach + // to ensure backwards compability. + var argMemberPath = argMemberPathStr + .TrimStart(FullNameOfPrefix) + .Split(MemberPathConstants.MemberAccessSeparator) + .Skip(1) + .ToImmutableEquatableArray(); + return new StringMemberPath(argMemberPath); + } + + var memberPath = new List(); + while (memberRefOperation != null) + { + memberPath.Add(memberRefOperation.Member); + memberRefOperation = memberRefOperation.GetFirstChildOperation() as IMemberReferenceOperation; + + // if not fullNameOf only consider the last member path segment + if (!fullNameOf && memberPath.Count > 1) + break; + } + + memberPath.Reverse(); + return new SymbolMemberPath(memberPath.ToImmutableEquatableArray()); + } + + private static object?[] BuildArrayValue(TypedConstant arg, Type targetType, SymbolAccessor? symbolAccessor) { if (!targetType.IsGenericType || targetType.GetGenericTypeDefinition() != typeof(IReadOnlyCollection<>)) throw new InvalidOperationException($"{nameof(IReadOnlyCollection)} is the only supported array type"); var elementTargetType = targetType.GetGenericArguments()[0]; - return arg.Values.Select(x => BuildArgumentValue(x, elementTargetType, null)).ToArray(); + return arg.Values.Select(x => BuildArgumentValue(x, elementTargetType, null, symbolAccessor)).ToArray(); } private static object? GetEnumValue(TypedConstant arg, Type targetType) diff --git a/src/Riok.Mapperly/Configuration/IMemberPathConfiguration.cs b/src/Riok.Mapperly/Configuration/IMemberPathConfiguration.cs new file mode 100644 index 0000000000..e50ad72a9d --- /dev/null +++ b/src/Riok.Mapperly/Configuration/IMemberPathConfiguration.cs @@ -0,0 +1,22 @@ +namespace Riok.Mapperly.Configuration; + +/// +/// A user-configured member path. +/// +public interface IMemberPathConfiguration +{ + /// + /// The name of the root member. + /// + string RootName { get; } + + /// + /// The full name e.g. A.B.C + /// + string FullName { get; } + + /// + /// The number of path segments in this path. + /// + int PathCount { get; } +} diff --git a/src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs index 2beb4bf325..f5cfb20f96 100644 --- a/src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs @@ -4,12 +4,12 @@ namespace Riok.Mapperly.Configuration; [DebuggerDisplay("{Source} => {Target}")] -public record MemberMappingConfiguration(StringMemberPath Source, StringMemberPath Target) : HasSyntaxReference +public record MemberMappingConfiguration(IMemberPathConfiguration Source, IMemberPathConfiguration Target) : HasSyntaxReference { /// /// Used to adapt from /// - public MemberMappingConfiguration(StringMemberPath Target) + public MemberMappingConfiguration(IMemberPathConfiguration Target) : this(Source: StringMemberPath.Empty, Target) { } public string? StringFormat { get; set; } diff --git a/src/Riok.Mapperly/Configuration/MemberPathConstants.cs b/src/Riok.Mapperly/Configuration/MemberPathConstants.cs new file mode 100644 index 0000000000..5724661d2d --- /dev/null +++ b/src/Riok.Mapperly/Configuration/MemberPathConstants.cs @@ -0,0 +1,7 @@ +namespace Riok.Mapperly.Configuration; + +internal static class MemberPathConstants +{ + public const char MemberAccessSeparator = '.'; + public const string MemberAccessSeparatorString = "."; +} diff --git a/src/Riok.Mapperly/Configuration/MemberValueMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/MemberValueMappingConfiguration.cs index ec205b5876..086cfc5ead 100644 --- a/src/Riok.Mapperly/Configuration/MemberValueMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MemberValueMappingConfiguration.cs @@ -3,12 +3,12 @@ namespace Riok.Mapperly.Configuration; [DebuggerDisplay("{Target}: {DescribeValue()}")] -public record MemberValueMappingConfiguration(StringMemberPath Target, AttributeValue? Value) : HasSyntaxReference +public record MemberValueMappingConfiguration(IMemberPathConfiguration Target, AttributeValue? Value) : HasSyntaxReference { /// /// Constructor used by . /// - public MemberValueMappingConfiguration(StringMemberPath target) + public MemberValueMappingConfiguration(IMemberPathConfiguration target) : this(target, null) { } public string? Use { get; set; } diff --git a/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs index d5123fa664..f82e7512e0 100644 --- a/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MembersMappingConfiguration.cs @@ -17,10 +17,10 @@ public IEnumerable GetMembersWithExplicitConfigurations(MappingSourceTar { var members = sourceTarget switch { - MappingSourceTarget.Source => ExplicitMappings.Where(x => x.Source.Path.Count > 0).Select(x => x.Source.Path[0]), + MappingSourceTarget.Source => ExplicitMappings.Where(x => x.Source.PathCount > 0).Select(x => x.Source.RootName), MappingSourceTarget.Target => ExplicitMappings - .Select(x => x.Target.Path[0]) - .Concat(ValueMappings.Select(x => x.Target.Path[0])), + .Select(x => x.Target.RootName) + .Concat(ValueMappings.Select(x => x.Target.RootName)), _ => throw new ArgumentOutOfRangeException(nameof(sourceTarget), sourceTarget, "Neither source or target"), }; return members.Distinct(); diff --git a/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs index 98f8eea473..813d4f72e2 100644 --- a/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/NestedMembersMappingConfiguration.cs @@ -1,3 +1,3 @@ namespace Riok.Mapperly.Configuration; -public record NestedMembersMappingConfiguration(StringMemberPath Source); +public record NestedMembersMappingConfiguration(IMemberPathConfiguration Source); diff --git a/src/Riok.Mapperly/Configuration/StringMemberPath.cs b/src/Riok.Mapperly/Configuration/StringMemberPath.cs index cafe98f567..41d7f7a319 100644 --- a/src/Riok.Mapperly/Configuration/StringMemberPath.cs +++ b/src/Riok.Mapperly/Configuration/StringMemberPath.cs @@ -2,17 +2,16 @@ namespace Riok.Mapperly.Configuration; -public readonly record struct StringMemberPath(ImmutableEquatableArray Path) +public readonly record struct StringMemberPath(ImmutableEquatableArray Path) : IMemberPathConfiguration { public static readonly StringMemberPath Empty = new(ImmutableEquatableArray.Empty); public StringMemberPath(IEnumerable path) : this(path.ToImmutableEquatableArray()) { } - public const char MemberAccessSeparator = '.'; - private const string MemberAccessSeparatorString = "."; - - public string FullName => string.Join(MemberAccessSeparatorString, Path); + public string RootName => Path[0]; + public string FullName => string.Join(MemberPathConstants.MemberAccessSeparatorString, Path); + public int PathCount => Path.Count; public override string ToString() => FullName; diff --git a/src/Riok.Mapperly/Configuration/SymbolMemberPath.cs b/src/Riok.Mapperly/Configuration/SymbolMemberPath.cs new file mode 100644 index 0000000000..0cc7518744 --- /dev/null +++ b/src/Riok.Mapperly/Configuration/SymbolMemberPath.cs @@ -0,0 +1,21 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Configuration; + +/// +/// A configured member path consisting of resolved symbols. +/// +/// The path. +public record SymbolMemberPath(ImmutableEquatableArray Path) : IMemberPathConfiguration +{ + private string? _fullName; + + public string RootName => Path[0].Name; + public string FullName => _fullName ??= string.Join(MemberPathConstants.MemberAccessSeparatorString, Path.Select(x => x.Name)); + public int PathCount => Path.Count; + + public override string ToString() => FullName; + + public StringMemberPath ToStringMemberPath() => new(Path.Select(x => x.Name)); +} diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs index 1633f173bd..85caf32fc7 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs @@ -73,7 +73,7 @@ IEnumerable allMembers foreach (var member in unmatchedMembers) { - if (member.Contains(StringMemberPath.MemberAccessSeparator, StringComparison.Ordinal)) + if (member.Contains(MemberPathConstants.MemberAccessSeparator, StringComparison.Ordinal)) { ctx.ReportDiagnostic(nestedDiagnostic, member, type); continue; diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs index 45c85fe8b9..bc84bdc6e2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingBuilderContext.cs @@ -269,7 +269,7 @@ private bool TryFindSourcePath( { if (TryGetMemberConfigs(targetMember.Name, false, out var memberConfigs)) { - var memberConfig = memberConfigs.FirstOrDefault(x => x.Target.Path.Count == 1); + var memberConfig = memberConfigs.FirstOrDefault(x => x.Target.PathCount == 1); if (memberConfig != null && ResolveMemberConfigSourcePath(memberConfig, out var sourceMember)) { return new MemberMappingInfo(sourceMember, new NonEmptyMemberPath(BuilderContext.Target, [targetMember]), memberConfig); @@ -278,7 +278,7 @@ private bool TryFindSourcePath( if (ignoreCase && TryGetMemberConfigs(targetMember.Name, true, out memberConfigs)) { - var memberConfig = memberConfigs.FirstOrDefault(x => x.Target.Path.Count == 1); + var memberConfig = memberConfigs.FirstOrDefault(x => x.Target.PathCount == 1); if (memberConfig != null && ResolveMemberConfigSourcePath(memberConfig, out var sourceMember)) { return new MemberMappingInfo(sourceMember, new NonEmptyMemberPath(BuilderContext.Target, [targetMember]), memberConfig); @@ -318,7 +318,7 @@ private bool TryGetConfiguredMemberMappingInfo( { if (TryGetMemberValueConfigs(targetMember.Name, false, out var memberValueConfigs)) { - var memberValueConfig = memberValueConfigs.FirstOrDefault(x => x.Target.Path.Count == 1); + var memberValueConfig = memberValueConfigs.FirstOrDefault(x => x.Target.PathCount == 1); if (memberValueConfig != null) { return new MemberMappingInfo(new NonEmptyMemberPath(BuilderContext.Target, [targetMember]), memberValueConfig); @@ -327,7 +327,7 @@ private bool TryGetConfiguredMemberMappingInfo( if (ignoreCase && TryGetMemberValueConfigs(targetMember.Name, true, out memberValueConfigs)) { - var memberValueConfig = memberValueConfigs.FirstOrDefault(x => x.Target.Path.Count == 1); + var memberValueConfig = memberValueConfigs.FirstOrDefault(x => x.Target.PathCount == 1); if (memberValueConfig != null) { return new MemberMappingInfo(new NonEmptyMemberPath(BuilderContext.Target, [targetMember]), memberValueConfig); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs index ba26ce10cc..f1e54a3526 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs @@ -204,15 +204,15 @@ private void SetSourceMemberMapped(SourceMemberPath sourcePath) } } - private void ConsumeMemberConfig(T config, StringMemberPath targetPath, Dictionary> configsByRootName) + private void ConsumeMemberConfig(T config, IMemberPathConfiguration targetPath, Dictionary> configsByRootName) { - if (!configsByRootName.TryGetValue(targetPath.Path[0], out var configs)) + if (!configsByRootName.TryGetValue(targetPath.RootName, out var configs)) return; configs.Remove(config); if (configs.Count == 0) { - configsByRootName.Remove(targetPath.Path[0]); + configsByRootName.Remove(targetPath.RootName); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index 7ab9138e8e..0618dbd9bb 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -12,7 +12,7 @@ internal static class MembersMappingStateBuilder public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapping) { // build configurations - var configuredTargetMembers = new HashSet(); + var configuredTargetMembers = new HashSet(); var memberValueConfigsByRootTargetName = BuildMemberValueConfigurations(ctx, mapping, configuredTargetMembers); var memberConfigsByRootTargetName = BuildMemberConfigurations(ctx, mapping, configuredTargetMembers); @@ -76,18 +76,18 @@ private static Dictionary GetTargetMembers(MappingBuild private static Dictionary> BuildMemberValueConfigurations( MappingBuilderContext ctx, IMapping mapping, - HashSet configuredTargetMembers + HashSet configuredTargetMembers ) { return GetUniqueTargetConfigurations(ctx, mapping, configuredTargetMembers, ctx.Configuration.Members.ValueMappings, x => x.Target) - .GroupBy(x => x.Target.Path[0]) + .GroupBy(x => x.Target.RootName) .ToDictionary(x => x.Key, x => x.ToList()); } private static Dictionary> BuildMemberConfigurations( MappingBuilderContext ctx, IMapping mapping, - HashSet configuredTargetMembers + HashSet configuredTargetMembers ) { // order by target path count as objects with less path depth should be mapped first @@ -99,16 +99,16 @@ HashSet configuredTargetMembers ctx.Configuration.Members.ExplicitMappings, x => x.Target ) - .GroupBy(x => x.Target.Path[0]) - .ToDictionary(x => x.Key, x => x.OrderBy(cfg => cfg.Target.Path.Count).ToList()); + .GroupBy(x => x.Target.RootName) + .ToDictionary(x => x.Key, x => x.OrderBy(cfg => cfg.Target.PathCount).ToList()); } private static IEnumerable GetUniqueTargetConfigurations( MappingBuilderContext ctx, IMapping mapping, - HashSet configuredTargetMembers, + HashSet configuredTargetMembers, IEnumerable configs, - Func targetPathSelector + Func targetPathSelector ) { foreach (var config in configs) diff --git a/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs index 9de59fd24c..a856895c59 100644 --- a/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs @@ -75,23 +75,7 @@ protected SimpleMappingBuilderContext(SimpleMappingBuilderContext ctx, Location? /// protected InlinedExpressionMappingCollection InlinedMappings { get; } = inlinedMappings; - public SemanticModel? GetSemanticModel(SyntaxTree syntaxTree) - { - if (_compilationContext.Compilation.ContainsSyntaxTree(syntaxTree)) - { - return _compilationContext.Compilation.GetSemanticModel(syntaxTree); - } - - foreach (var compilation in _compilationContext.NestedCompilations) - { - if (compilation.ContainsSyntaxTree(syntaxTree)) - { - return compilation.GetSemanticModel(syntaxTree); - } - } - - return null; - } + public SemanticModel? GetSemanticModel(SyntaxTree syntaxTree) => _compilationContext.GetSemanticModel(syntaxTree); public virtual bool IsConversionEnabled(MappingConversionType conversionType) => Configuration.Mapper.EnabledConversions.HasFlag(conversionType); diff --git a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs index 159b2775a9..5222463e65 100644 --- a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs +++ b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs @@ -320,6 +320,40 @@ internal bool TryFindMemberPath( return false; } + internal bool TryFindMemberPath(ITypeSymbol type, IMemberPathConfiguration path, [NotNullWhen(true)] out MemberPath? memberPath) + { + if (path is StringMemberPath stringMemberPath) + return TryFindMemberPath(type, stringMemberPath, out memberPath); + + // resolve from symbol member path + // if it is not possible to resolve by direct symbols + // the string path is tried to ensure backwards compatibility + // (e.g. when the A.MyValue is referenced, + // but instead B.MyValue is the correct one, + // with the string representation it doesn't matter, it is just MyValue). + var symbolMemberPath = (SymbolMemberPath)path; + var memberPathSegments = new List(symbolMemberPath.PathCount); + foreach (var pathSegment in symbolMemberPath.Path) + { + if (MappableMember.Create(this, pathSegment) is { } mappableMember) + { + memberPathSegments.Add(mappableMember); + continue; + } + + return TryFindMemberPath(type, symbolMemberPath.ToStringMemberPath(), out memberPath); + } + + var nameOfRefType = memberPathSegments[0].ContainingType; + if (nameOfRefType == null || !CanAssign(type, nameOfRefType)) + { + return TryFindMemberPath(type, symbolMemberPath.ToStringMemberPath(), out memberPath); + } + + memberPath = new NonEmptyMemberPath(type, memberPathSegments); + return true; + } + internal bool TryFindMemberPath(ITypeSymbol type, StringMemberPath path, [NotNullWhen(true)] out MemberPath? memberPath) { var foundPath = new List(); @@ -363,6 +397,8 @@ private bool TryFindPath(ITypeSymbol type, StringMemberPath path, bool ignoreCas return symbolMembers.GetValueOrDefault(name); } + public IOperation? GetOperation(SyntaxNode node) => compilationContext.GetSemanticModel(node.SyntaxTree)?.GetOperation(node); + private ImmutableArray GetAttributesCore(ISymbol symbol) { if (_attributes.TryGetValue(symbol, out var attributes)) diff --git a/src/Riok.Mapperly/Helpers/OperationExtensions.cs b/src/Riok.Mapperly/Helpers/OperationExtensions.cs new file mode 100644 index 0000000000..7741923400 --- /dev/null +++ b/src/Riok.Mapperly/Helpers/OperationExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.CodeAnalysis; + +namespace Riok.Mapperly.Helpers; + +internal static class OperationExtensions +{ + public static IOperation? GetFirstChildOperation(this IOperation operation) + { +#if ROSLYN4_4_OR_GREATER + return operation.ChildOperations.FirstOrDefault(); +#else + return operation.Children.FirstOrDefault(); +#endif + } +} diff --git a/src/Riok.Mapperly/Symbols/CompilationContext.cs b/src/Riok.Mapperly/Symbols/CompilationContext.cs index 1332855555..bec42d76a9 100644 --- a/src/Riok.Mapperly/Symbols/CompilationContext.cs +++ b/src/Riok.Mapperly/Symbols/CompilationContext.cs @@ -10,4 +10,23 @@ public sealed record CompilationContext( WellKnownTypes Types, ImmutableArray NestedCompilations, FileNameBuilder FileNameBuilder -); +) +{ + public SemanticModel? GetSemanticModel(SyntaxTree tree) + { + if (Compilation.ContainsSyntaxTree(tree)) + { + return Compilation.GetSemanticModel(tree); + } + + foreach (var compilation in NestedCompilations) + { + if (compilation.ContainsSyntaxTree(tree)) + { + return compilation.GetSemanticModel(tree); + } + } + + return null; + } +} diff --git a/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs b/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs index e30ee348d1..d49fe522c8 100644 --- a/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs @@ -20,6 +20,7 @@ public class ConstructorParameterMember(IParameterSymbol symbol, SymbolAccessor IMemberGetter { public ITypeSymbol Type { get; } = accessor.UpgradeNullable(symbol.Type); + public INamedTypeSymbol ContainingType { get; } = symbol.ContainingType; public bool IsNullable => Symbol.NullableAnnotation.IsNullable(); public bool CanGet => false; public bool CanGetDirectly => false; diff --git a/src/Riok.Mapperly/Symbols/Members/FieldMember.cs b/src/Riok.Mapperly/Symbols/Members/FieldMember.cs index 5bf619a3f3..ea258e9f4c 100644 --- a/src/Riok.Mapperly/Symbols/Members/FieldMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/FieldMember.cs @@ -18,6 +18,7 @@ public class FieldMember(IFieldSymbol symbol, SymbolAccessor symbolAccessor) IMemberSetter { public ITypeSymbol Type { get; } = symbolAccessor.UpgradeNullable(symbol.Type); + public INamedTypeSymbol ContainingType { get; } = symbol.ContainingType; public bool IsNullable => Type.IsNullable(); public bool CanGet => true; public bool CanGetDirectly => symbolAccessor.IsDirectlyAccessible(Symbol); diff --git a/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs b/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs index c71c0e563a..b1058e8520 100644 --- a/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs @@ -13,6 +13,8 @@ public interface IMappableMember ITypeSymbol Type { get; } + INamedTypeSymbol? ContainingType { get; } + bool IsNullable { get; } /// diff --git a/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs b/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs index 0cd71d02d6..1dc39a84cc 100644 --- a/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs @@ -17,6 +17,7 @@ public class ParameterSourceMember(MethodParameter parameter) : IMappableMember, { public string Name => parameter.Name; public ITypeSymbol Type => parameter.Type; + public INamedTypeSymbol? ContainingType => null; public bool IsNullable => parameter.Type.IsNullable(); public bool CanGet => true; public bool CanGetDirectly => true; diff --git a/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs b/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs index 79da441bad..c902ece621 100644 --- a/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs @@ -18,6 +18,9 @@ public class PropertyMember(IPropertySymbol symbol, SymbolAccessor symbolAccesso IMemberGetter { public ITypeSymbol Type { get; } = symbolAccessor.UpgradeNullable(symbol.Type); + + public INamedTypeSymbol? ContainingType { get; } = symbol.ContainingType; + public bool IsNullable => Type.IsNullable(); public bool CanGet => !Symbol.IsWriteOnly && (Symbol.GetMethod == null || symbolAccessor.IsMemberAccessible(Symbol.GetMethod)); diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs index 1a9c8f9d0d..4491c343fd 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs @@ -8,7 +8,7 @@ public class ObjectPropertyFlatteningTest public void ManualFlattenedPropertyWithFullNameOfSource() { var source = TestSourceBuilder.MapperWithBodyAndTypes( - "[MapProperty(nameof(@C.Value.Id), nameof(B.MyValueId))] partial B Map(A source);", + "[MapProperty(nameof(@A.Value.Id), nameof(B.MyValueId))] partial B Map(A source);", "class A { public C Value { get; set; } }", "class B { public string MyValueId { get; set; } }", "class C { public string Id { get; set; }" @@ -26,6 +26,73 @@ public void ManualFlattenedPropertyWithFullNameOfSource() ); } + [Fact] + public void ManualFlattenedPropertyWithFullNameOfSourceAndWrongType() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(@D.Value.Id), nameof(D.MyValueId))] partial B Map(A source);", + "class A { public C Value { get; set; } }", + "class B { public string MyValueId { get; set; } }", + "class C { public string Id { get; set; }", + "class D { public string? MyValueId { get; set; } public C? Value { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.MyValueId = source.Value.Id; + return target; + """ + ); + } + + [Fact] + public void ManualFlattenedPropertyWithFullNameOfNamespacedSource() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(@My.A.Value.Id), nameof(B.MyValueId))] partial B Map(My.A source);", + "namespace My { class A { public C Value { get; set; } } } ", + "class B { public string MyValueId { get; set; } }", + "class C { public string Id { get; set; }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.MyValueId = source.Value.Id; + return target; + """ + ); + } + + [Fact] + public void ManualFlattenedPropertyWithFullNameOfNestedSource() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(@My.Nested.A.Value.Id), nameof(B.MyValueId))] partial B Map(My.Nested.A source);", + "namespace My { public class Nested { public class A { public C Value { get; set; } } } }", + "class B { public string MyValueId { get; set; } }", + "class C { public string Id { get; set; }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.MyValueId = source.Value.Id; + return target; + """ + ); + } + [Fact] public void ManualFlattenedPropertyWithSourceArray() { @@ -373,7 +440,7 @@ public void ManualUnflattenedProperty() public void ManualUnflattenedPropertyWithFullNameOfTarget() { var source = TestSourceBuilder.MapperWithBodyAndTypes( - "[MapProperty(nameof(C.MyValueId), nameof(@B.Value.Id))] partial B Map(A source);", + "[MapProperty(nameof(A.MyValueId), nameof(@B.Value.Id))] partial B Map(A source);", "class A { public string MyValueId { get; set; } }", "class B { public C Value { get; set; } }", "class C { public string Id { get; set; }" @@ -417,7 +484,7 @@ public void ManualUnflattenedPropertyWithTargetArray() public void ManualUnflattenedPropertyInterpolatedFullNameOfTarget() { var source = TestSourceBuilder.MapperWithBodyAndTypes( - """[MapProperty(nameof(C.MyValueId), $"{nameof(B.Value)}.{nameof(B.Value.Id)}")] partial B Map(A source);""", + """[MapProperty(nameof(A.MyValueId), $"{nameof(B.Value)}.{nameof(B.Value.Id)}")] partial B Map(A source);""", "class A { public string MyValueId { get; set; } }", "class B { public C Value { get; set; } }", "class C { public string Id { get; set; }"