Skip to content

Commit

Permalink
improve fullnameof and allow namespaced and nested types (#1518)
Browse files Browse the repository at this point in the history
  • Loading branch information
latonz authored Oct 11, 2024
1 parent 2c3982e commit a667d41
Show file tree
Hide file tree
Showing 24 changed files with 299 additions and 87 deletions.
15 changes: 4 additions & 11 deletions docs/docs/configuration/flattening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `@`.

Expand All @@ -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`.
93 changes: 67 additions & 26 deletions src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -52,11 +53,11 @@ public IEnumerable<TData> Access<TAttribute, TData>(ISymbol symbol)
var attrDatas = symbolAccessor.GetAttributes<TAttribute>(symbol);
foreach (var attrData in attrDatas)
{
yield return Access<TAttribute, TData>(attrData);
yield return Access<TAttribute, TData>(attrData, symbolAccessor);
}
}

internal static TData Access<TAttribute, TData>(AttributeData attrData)
internal static TData Access<TAttribute, TData>(AttributeData attrData, SymbolAccessor? symbolAccessor = null)
where TAttribute : Attribute
where TData : notnull
{
Expand All @@ -68,7 +69,7 @@ internal static TData Access<TAttribute, TData>(AttributeData attrData)
(IReadOnlyList<AttributeArgumentSyntax>?)syntax?.ArgumentList?.Arguments
?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length];
var typeArguments = (IReadOnlyCollection<ITypeSymbol>?)attrData.AttributeClass?.TypeArguments ?? [];
var attr = Create<TData>(typeArguments, attrData.ConstructorArguments, syntaxArguments);
var attr = Create<TData>(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());
Expand All @@ -77,7 +78,7 @@ internal static TData Access<TAttribute, TData>(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++;
}
Expand All @@ -93,7 +94,8 @@ internal static TData Access<TAttribute, TData>(AttributeData attrData)
private static TData Create<TData>(
IReadOnlyCollection<ITypeSymbol> typeArguments,
IReadOnlyCollection<TypedConstant> constructorArguments,
IReadOnlyList<AttributeArgumentSyntax> argumentSyntax
IReadOnlyList<AttributeArgumentSyntax> argumentSyntax,
SymbolAccessor? symbolAccessor
)
where TData : notnull
{
Expand All @@ -110,7 +112,7 @@ IReadOnlyList<AttributeArgumentSyntax> 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))
Expand All @@ -125,7 +127,12 @@ IReadOnlyList<AttributeArgumentSyntax> 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
{
Expand All @@ -134,9 +141,9 @@ IReadOnlyList<AttributeArgumentSyntax> 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(
Expand All @@ -145,12 +152,21 @@ IReadOnlyList<AttributeArgumentSyntax> 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);
Expand All @@ -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<ISymbol>();
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<object>)} 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)
Expand Down
22 changes: 22 additions & 0 deletions src/Riok.Mapperly/Configuration/IMemberPathConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Riok.Mapperly.Configuration;

/// <summary>
/// A user-configured member path.
/// </summary>
public interface IMemberPathConfiguration
{
/// <summary>
/// The name of the root member.
/// </summary>
string RootName { get; }

/// <summary>
/// The full name e.g. A.B.C
/// </summary>
string FullName { get; }

/// <summary>
/// The number of path segments in this path.
/// </summary>
int PathCount { get; }
}
4 changes: 2 additions & 2 deletions src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Used to adapt from <see cref="Abstractions.MapPropertyFromSourceAttribute"/>
/// </summary>
public MemberMappingConfiguration(StringMemberPath Target)
public MemberMappingConfiguration(IMemberPathConfiguration Target)
: this(Source: StringMemberPath.Empty, Target) { }

public string? StringFormat { get; set; }
Expand Down
7 changes: 7 additions & 0 deletions src/Riok.Mapperly/Configuration/MemberPathConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Riok.Mapperly.Configuration;

internal static class MemberPathConstants
{
public const char MemberAccessSeparator = '.';
public const string MemberAccessSeparatorString = ".";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Constructor used by <see cref="AttributeDataAccessor"/>.
/// </summary>
public MemberValueMappingConfiguration(StringMemberPath target)
public MemberValueMappingConfiguration(IMemberPathConfiguration target)
: this(target, null) { }

public string? Use { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ public IEnumerable<string> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
namespace Riok.Mapperly.Configuration;

public record NestedMembersMappingConfiguration(StringMemberPath Source);
public record NestedMembersMappingConfiguration(IMemberPathConfiguration Source);
9 changes: 4 additions & 5 deletions src/Riok.Mapperly/Configuration/StringMemberPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@

namespace Riok.Mapperly.Configuration;

public readonly record struct StringMemberPath(ImmutableEquatableArray<string> Path)
public readonly record struct StringMemberPath(ImmutableEquatableArray<string> Path) : IMemberPathConfiguration
{
public static readonly StringMemberPath Empty = new(ImmutableEquatableArray<string>.Empty);

public StringMemberPath(IEnumerable<string> 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;

Expand Down
21 changes: 21 additions & 0 deletions src/Riok.Mapperly/Configuration/SymbolMemberPath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Configuration;

/// <summary>
/// A configured member path consisting of resolved symbols.
/// </summary>
/// <param name="Path">The path.</param>
public record SymbolMemberPath(ImmutableEquatableArray<ISymbol> 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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ IEnumerable<string> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading

0 comments on commit a667d41

Please sign in to comment.