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

feat: introduce experimental full nameof support #518

Merged
merged 1 commit into from
Aug 8, 2023
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
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 @@
/// </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 @@

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 @@
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 @@
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 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 @@
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();

Check warning on line 171 in src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs#L171

Added line #L171 was not covered by tests
}

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
Loading