Skip to content

Commit

Permalink
feat: introduce experimental full nameof support (#518)
Browse files Browse the repository at this point in the history
  • Loading branch information
latonz authored Aug 8, 2023
1 parent 06b3c87 commit 43e9b19
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 10 deletions.
22 changes: 22 additions & 0 deletions docs/docs/configuration/flattening.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,31 @@ by either using the source and target property path names as arrays or using a d
[MapProperty(new[] { nameof(Car.Make), nameof(Car.Make.Id) }, new[] { nameof(CarDto.MakeId) })]
// Or alternatively
[MapProperty("Make.Id", "MakeId")]
// Or
[MapProperty($"{nameof(Make)}.{nameof(Make.Id)}", "MakeId")]
partial CarDto Map(Car car);
```

:::info
Unflattening is not automatically configured by Mapperly and needs to be configured manually via `MapPropertyAttribute`.
:::

## Experimental full `nameof`

Mapperly supports an experimental "fullnameof".
It can be used to configure property paths using `nameof`.
Opt-in is done by prefixing the path with `@`.

```csharp
[MapProperty(nameof(@Car.Make.Id), nameof(CarDto.MakeId))]
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.
:::
1 change: 1 addition & 0 deletions docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public partial class CarMapper

On each mapping method declaration, property and field mappings can be customized.
If a property or field on the target has a different name than on the source, the `MapPropertyAttribute` can be applied.
See also the documentation on [flattening / unflattening](./flattening.md).

```csharp
[Mapper]
Expand Down
2 changes: 1 addition & 1 deletion docs/src/plugins/rehype/rehype-faq/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { toHtml } from 'hast-util-to-html';
If working on this script,
testing changes needs the .docusaurus cache directory to be cleared.
Eg. `rm -rf .docusaurus; npm run start`
Eg. `npm run clear; npm run start`
*/

const faqFileName = 'faq.md';
Expand Down
56 changes: 47 additions & 9 deletions src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors;
using Riok.Mapperly.Helpers;

Expand All @@ -10,6 +11,9 @@ namespace Riok.Mapperly.Configuration;
/// </summary>
internal class AttributeDataAccessor
{
private const string NameOfOperatorName = "nameof";
private const char FullNameOfPrefix = '@';

private readonly SymbolAccessor _symbolAccessor;

public AttributeDataAccessor(SymbolAccessor symbolAccessor)
Expand Down Expand Up @@ -49,23 +53,33 @@ public IEnumerable<TData> Access<TAttribute, TData>(ISymbol symbol)

foreach (var attrData in attrDatas)
{
var syntax = (AttributeSyntax?)attrData.ApplicationSyntaxReference?.GetSyntax();
var syntaxArguments =
(IReadOnlyList<AttributeArgumentSyntax>?)syntax?.ArgumentList?.Arguments
?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length];
var typeArguments = (IReadOnlyCollection<ITypeSymbol>?)attrData.AttributeClass?.TypeArguments ?? Array.Empty<ITypeSymbol>();
var attr = Create<TData>(typeArguments, attrData.ConstructorArguments);
var attr = Create<TData>(typeArguments, attrData.ConstructorArguments, syntaxArguments);

var syntaxIndex = attrData.ConstructorArguments.Length;
foreach (var namedArgument in attrData.NamedArguments)
{
var prop = dataType.GetProperty(namedArgument.Key);
if (prop == null)
throw new InvalidOperationException($"Could not get property {namedArgument.Key} of attribute {attrType.FullName}");

prop.SetValue(attr, BuildArgumentValue(namedArgument.Value, prop.PropertyType));
prop.SetValue(attr, BuildArgumentValue(namedArgument.Value, prop.PropertyType, syntaxArguments[syntaxIndex]));
syntaxIndex++;
}

yield return attr;
}
}

private TData Create<TData>(IReadOnlyCollection<ITypeSymbol> typeArguments, IReadOnlyCollection<TypedConstant> constructorArguments)
private TData Create<TData>(
IReadOnlyCollection<ITypeSymbol> typeArguments,
IReadOnlyCollection<TypedConstant> constructorArguments,
IReadOnlyList<AttributeArgumentSyntax> argumentSyntax
)
where TData : notnull
{
// The data class should have a constructor
Expand All @@ -81,7 +95,7 @@ private TData Create<TData>(IReadOnlyCollection<ITypeSymbol> typeArguments, IRea
continue;

var constructorArgumentValues = constructorArguments.Select(
(arg, i) => BuildArgumentValue(arg, parameters[i + typeArguments.Count].ParameterType)
(arg, i) => BuildArgumentValue(arg, parameters[i + typeArguments.Count].ParameterType, argumentSyntax[i])
);
var constructorTypeAndValueArguments = typeArguments.Concat(constructorArgumentValues).ToArray();
return (TData?)Activator.CreateInstance(typeof(TData), constructorTypeAndValueArguments)
Expand All @@ -91,12 +105,12 @@ private TData Create<TData>(IReadOnlyCollection<ITypeSymbol> typeArguments, IRea
throw new InvalidOperationException($"{typeof(TData)} does not have a constructor with {argCount} parameters");
}

private static object? BuildArgumentValue(TypedConstant arg, Type targetType)
private static object? BuildArgumentValue(TypedConstant arg, Type targetType, AttributeArgumentSyntax? syntax)
{
return arg.Kind switch
{
_ when arg.IsNull => null,
_ when targetType == typeof(StringMemberPath) => CreateMemberPath(arg),
_ when targetType == typeof(StringMemberPath) => CreateMemberPath(arg, syntax),
TypedConstantKind.Enum => GetEnumValue(arg, targetType),
TypedConstantKind.Array => BuildArrayValue(arg, targetType),
TypedConstantKind.Primitive => arg.Value,
Expand All @@ -108,14 +122,38 @@ private TData Create<TData>(IReadOnlyCollection<ITypeSymbol> typeArguments, IRea
};
}

private static StringMemberPath CreateMemberPath(TypedConstant arg)
private static StringMemberPath CreateMemberPath(TypedConstant arg, AttributeArgumentSyntax? syntax)
{
if (arg.Kind == TypedConstantKind.Array)
{
var values = arg.Values.Select(x => (string?)BuildArgumentValue(x, typeof(string))).WhereNotNull().ToList();
var values = arg.Values.Select(x => (string?)BuildArgumentValue(x, typeof(string), null)).WhereNotNull().ToList();
return new StringMemberPath(values);
}

// try to extract the full nameof path from syntax
if (
arg.Kind == TypedConstantKind.Primitive
&& syntax?.Expression
is InvocationExpressionSyntax
{
Expression: IdentifierNameSyntax { Identifier.Text: NameOfOperatorName }
} 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.PropertyAccessSeparator)
.Skip(1)
.ToArray();
return new StringMemberPath(argMemberPath);
}
}

if (arg is { Kind: TypedConstantKind.Primitive, Value: string v })
{
return new StringMemberPath(v.Split(StringMemberPath.PropertyAccessSeparator));
Expand All @@ -130,7 +168,7 @@ private static StringMemberPath CreateMemberPath(TypedConstant arg)
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)).ToArray();
return arg.Values.Select(x => BuildArgumentValue(x, elementTargetType, null)).ToArray();
}

private static object? GetEnumValue(TypedConstant arg, Type targetType)
Expand Down
88 changes: 88 additions & 0 deletions test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@ namespace Riok.Mapperly.Tests.Mapping;
[UsesVerify]
public class ObjectPropertyFlatteningTest
{
[Fact]
public void ManualFlattenedPropertyWithFullNameOfSource()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapProperty(nameof(@C.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; }"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
var target = new global::B();
target.MyValueId = source.Value.Id;
return target;
"""
);
}

[Fact]
public void ManualFlattenedPropertyWithInterpolatedNameOfSource()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""[MapProperty($"{nameof(C.Value)}.{nameof(C.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; }"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
var target = new global::B();
target.MyValueId = source.Value.Id;
return target;
"""
);
}

[Fact]
public void ManualFlattenedProperty()
{
Expand Down Expand Up @@ -278,6 +322,50 @@ public void ManualUnflattenedProperty()
);
}

[Fact]
public void ManualUnflattenedPropertyWithFullNameOfTarget()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapProperty(nameof(C.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; }"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
var target = new global::B();
target.Value.Id = source.MyValueId;
return target;
"""
);
}

[Fact]
public void ManualUnflattenedPropertyInterpolatedFullNameOfTarget()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""[MapProperty(nameof(C.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; }"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
var target = new global::B();
target.Value.Id = source.MyValueId;
return target;
"""
);
}

[Fact]
public void ManualUnflattenedPropertyNullablePath()
{
Expand Down

0 comments on commit 43e9b19

Please sign in to comment.