Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve fullnameof and allow namespaced and nested types #1518

Merged
merged 1 commit into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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