Skip to content

Commit 81df5f4

Browse files
committed
feat: implement generic user-implemented mapping methods
1 parent c61a57a commit 81df5f4

22 files changed

+1151
-15
lines changed

docs/docs/configuration/user-implemented-methods.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,46 @@ The mapper will respect the `ref` keyword on the target parameter when using an
111111
private static void MapArray([MappingTarget] ref int[] target, int[] second) => target = [.. target, .. second.Except(target)];
112112
}
113113
```
114+
115+
## Generic user-implemented mapping methods
116+
117+
User-implemented mapping methods can be generic.
118+
Mapperly automatically resolves generic user-implemented mapping methods
119+
by inferring type arguments from the source and target types.
120+
121+
```csharp
122+
public record Document(string Title, User CreatedBy, Optional<User> ModifiedBy);
123+
public record DocumentDto(string Title, UserDto CreatedBy, Optional<UserDto> ModifiedBy);
124+
125+
public record User(string Name);
126+
public record UserDto(string Name);
127+
128+
[Mapper]
129+
public partial class MyMapper
130+
{
131+
public partial DocumentDto MapDocument(Document source);
132+
133+
// highlight-start
134+
private Optional<TTarget> MapOptional<TSource, TTarget>(Optional<TSource> source)
135+
where TSource : notnull
136+
where TTarget : notnull
137+
=> source.HasValue
138+
? Optional.Of(Map(source.Value))
139+
: Optional.Empty();
140+
// highlight-end
141+
142+
private partial TTarget Map<TSource, TTarget>(TSource source)
143+
where TSource : notnull
144+
where TTarget : notnull;
145+
146+
private partial UserDto MapUser(User source);
147+
}
148+
```
149+
150+
In this example, when Mapperly needs to map `Optional<User>` to `Optional<UserDto>`,
151+
it will match the `MapOptional` method and invoke it as `MapOptional<User, UserDto>(source.ModifiedBy)`.
152+
153+
:::info
154+
If a non-generic user-implemented mapping method exists for an exact type pair,
155+
it takes priority over a generic one.
156+
:::

src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ ImmutableArray<UseStaticMapperConfiguration> assemblyScopedStaticMappers
5252
_diagnostics = new DiagnosticCollection(mapperDeclaration.Syntax.GetLocation());
5353

5454
var genericTypeChecker = new GenericTypeChecker(_symbolAccessor, compilationContext.Types);
55+
_mappings.SetGenericTypeChecker(genericTypeChecker);
5556
var configurationReader = new MapperConfigurationReader(
5657
_attributeAccessor,
5758
_mappings,

src/Riok.Mapperly/Descriptors/MappingCollection.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ public class MappingCollection
4141
/// </summary>
4242
private readonly MappingCollectionInstance<IExistingTargetMapping, IExistingTargetUserMapping> _existingTargetMappings = new();
4343

44+
/// <summary>
45+
/// Generic user-implemented new instance mapping templates.
46+
/// These are matched against concrete type pairs during <see cref="FindNewInstanceMapping"/>.
47+
/// </summary>
48+
private readonly List<GenericUserImplementedNewInstanceMethodMapping> _genericNewInstanceTemplates = [];
49+
50+
/// <summary>
51+
/// Generic user-implemented existing target mapping templates.
52+
/// These are matched against concrete type pairs during <see cref="FindExistingInstanceMapping"/>.
53+
/// </summary>
54+
private readonly List<GenericUserImplementedExistingTargetMethodMapping> _genericExistingTargetTemplates = [];
55+
56+
private GenericTypeChecker? _genericTypeChecker;
57+
4458
/// <inheritdoc cref="_methodMappings"/>
4559
public IReadOnlyCollection<MethodMapping> MethodMappings => _methodMappings;
4660

@@ -62,15 +76,18 @@ public class MappingCollection
6276
.Concat(_newInstanceMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings)
6377
.Concat(_existingTargetMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings);
6478

79+
public void SetGenericTypeChecker(GenericTypeChecker genericTypeChecker) => _genericTypeChecker = genericTypeChecker;
80+
6581
public INewInstanceMapping? FindNewInstanceMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) =>
66-
_newInstanceMappings.Find(mappingKey, scope);
82+
_newInstanceMappings.Find(mappingKey, scope) ?? TryMatchGenericNewInstanceTemplate(mappingKey);
6783

6884
public INewInstanceUserMapping? FindNewInstanceUserMapping(IMethodSymbol method) => _newInstanceMappings.FindUserMapping(method);
6985

7086
public INewInstanceMapping? FindNamedNewInstanceMapping(string name, out bool ambiguousName) =>
7187
_newInstanceMappings.FindNamed(name, out ambiguousName);
7288

73-
public IExistingTargetMapping? FindExistingInstanceMapping(TypeMappingKey mappingKey) => _existingTargetMappings.Find(mappingKey);
89+
public IExistingTargetMapping? FindExistingInstanceMapping(TypeMappingKey mappingKey) =>
90+
_existingTargetMappings.Find(mappingKey) ?? TryMatchGenericExistingTargetTemplate(mappingKey);
7491

7592
public IExistingTargetMapping? FindExistingInstanceNamedMapping(string name, out bool ambiguousName) =>
7693
_existingTargetMappings.FindNamed(name, out ambiguousName);
@@ -88,6 +105,17 @@ public MappingCollectionAddResult AddUserMapping(IUserMapping userMapping, strin
88105
_methodMappings.Add(methodMapping);
89106
}
90107

108+
// Generic user-implemented mapping templates are stored separately
109+
// and matched lazily during Find operations.
110+
if (userMapping is GenericUserImplementedNewInstanceMethodMapping genericNewInstance)
111+
{
112+
_genericNewInstanceTemplates.Add(genericNewInstance);
113+
}
114+
else if (userMapping is GenericUserImplementedExistingTargetMethodMapping genericExistingTarget)
115+
{
116+
_genericExistingTargetTemplates.Add(genericExistingTarget);
117+
}
118+
91119
return userMapping switch
92120
{
93121
INewInstanceUserMapping newInstanceMapping => _newInstanceMappings.AddUserMapping(
@@ -158,6 +186,42 @@ public void AddNamedExistingInstanceUserMapping(string name, IExistingTargetUser
158186
_existingTargetMappings.AddNamedUserMapping(name, mapping);
159187
}
160188

189+
private INewInstanceMapping? TryMatchGenericNewInstanceTemplate(TypeMappingKey mappingKey)
190+
{
191+
if (_genericTypeChecker == null || _genericNewInstanceTemplates.Count == 0)
192+
return null;
193+
194+
foreach (var template in _genericNewInstanceTemplates)
195+
{
196+
if (template.TryCreateConcreteMapping(_genericTypeChecker, mappingKey.Source, mappingKey.Target) is not { } mapping)
197+
continue;
198+
199+
// Cache the concrete mapping so subsequent lookups find it directly.
200+
_newInstanceMappings.TryAddAsDefault(mapping, mappingKey.Configuration);
201+
return mapping;
202+
}
203+
204+
return null;
205+
}
206+
207+
private IExistingTargetMapping? TryMatchGenericExistingTargetTemplate(TypeMappingKey mappingKey)
208+
{
209+
if (_genericTypeChecker == null || _genericExistingTargetTemplates.Count == 0)
210+
return null;
211+
212+
foreach (var template in _genericExistingTargetTemplates)
213+
{
214+
if (template.TryCreateConcreteMapping(_genericTypeChecker, mappingKey.Source, mappingKey.Target) is not { } mapping)
215+
continue;
216+
217+
// Cache the concrete mapping so subsequent lookups find it directly.
218+
_existingTargetMappings.TryAddAsDefault(mapping, mappingKey.Configuration);
219+
return mapping;
220+
}
221+
222+
return null;
223+
}
224+
161225
private class MappingCollectionInstance<T, TUserMapping>
162226
where T : ITypeMapping
163227
where TUserMapping : T, IUserMapping
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp.Syntax;
3+
using Riok.Mapperly.Helpers;
4+
using Riok.Mapperly.Symbols;
5+
6+
namespace Riok.Mapperly.Descriptors.Mappings.UserMappings;
7+
8+
/// <summary>
9+
/// A template for a generic user-implemented existing target mapping method.
10+
/// This is not a concrete mapping but stores the information needed to create
11+
/// concrete instantiations when a matching type pair is found.
12+
/// The <see cref="Default"/> is always <c>false</c> so it is not added to the default mappings dictionary.
13+
/// </summary>
14+
internal sealed class GenericUserImplementedExistingTargetMethodMapping(
15+
string? receiver,
16+
IMethodSymbol method,
17+
MethodParameter sourceParameter,
18+
MethodParameter targetParameter,
19+
MethodParameter? referenceHandlerParameter,
20+
bool isExternal
21+
) : IExistingTargetUserMapping
22+
{
23+
public ITypeSymbol SourceType => sourceParameter.Type;
24+
25+
public ITypeSymbol TargetType => targetParameter.Type;
26+
27+
public IMethodSymbol Method => method;
28+
29+
public bool? Default => false;
30+
31+
public bool IsExternal => isExternal;
32+
33+
public bool IsSynthetic => false;
34+
35+
public IEnumerable<TypeMappingKey> BuildAdditionalMappingKeys(TypeMappingConfiguration config) => [];
36+
37+
public IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx, ExpressionSyntax target) =>
38+
throw new InvalidOperationException("Generic mapping template should not be built directly");
39+
40+
/// <summary>
41+
/// Tries to create a concrete mapping for the given source and target types
42+
/// by inferring the type arguments from the generic method signature.
43+
/// </summary>
44+
public UserImplementedGenericExistingTargetMethodMapping? TryCreateConcreteMapping(
45+
GenericTypeChecker checker,
46+
ITypeSymbol concreteSourceType,
47+
ITypeSymbol concreteTargetType
48+
)
49+
{
50+
var result = checker.InferAndCheckTypes(
51+
method.TypeParameters,
52+
(sourceParameter.Type, concreteSourceType),
53+
(targetParameter.Type, concreteTargetType)
54+
);
55+
56+
if (!result.Success)
57+
return null;
58+
59+
var typeArguments = method.TypeParameters.Select(tp => result.InferredTypes[tp]).ToList();
60+
61+
return new UserImplementedGenericExistingTargetMethodMapping(
62+
receiver,
63+
method,
64+
null,
65+
sourceParameter,
66+
targetParameter,
67+
concreteSourceType,
68+
concreteTargetType,
69+
typeArguments,
70+
referenceHandlerParameter,
71+
isExternal
72+
);
73+
}
74+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp.Syntax;
3+
using Riok.Mapperly.Helpers;
4+
using Riok.Mapperly.Symbols;
5+
6+
namespace Riok.Mapperly.Descriptors.Mappings.UserMappings;
7+
8+
/// <summary>
9+
/// A template for a generic user-implemented new instance mapping method.
10+
/// This is not a concrete mapping but stores the information needed to create
11+
/// concrete instantiations when a matching type pair is found.
12+
/// The <see cref="Default"/> is always <c>false</c> so it is not added to the default mappings dictionary.
13+
/// </summary>
14+
internal sealed class GenericUserImplementedNewInstanceMethodMapping(
15+
string? receiver,
16+
IMethodSymbol method,
17+
MethodParameter sourceParameter,
18+
ITypeSymbol genericTargetType,
19+
MethodParameter? referenceHandlerParameter,
20+
bool isExternal,
21+
UserImplementedMethodMapping.TargetNullability targetNullability
22+
) : INewInstanceUserMapping
23+
{
24+
public ITypeSymbol SourceType => sourceParameter.Type;
25+
26+
public ITypeSymbol TargetType => genericTargetType;
27+
28+
public IMethodSymbol Method => method;
29+
30+
public bool? Default => false;
31+
32+
public bool IsExternal => isExternal;
33+
34+
public bool IsSynthetic => false;
35+
36+
public IEnumerable<TypeMappingKey> BuildAdditionalMappingKeys(TypeMappingConfiguration config) => [];
37+
38+
public ExpressionSyntax Build(TypeMappingBuildContext ctx) =>
39+
throw new InvalidOperationException("Generic mapping template should not be built directly");
40+
41+
/// <summary>
42+
/// Tries to create a concrete mapping for the given source and target types
43+
/// by inferring the type arguments from the generic method signature.
44+
/// </summary>
45+
public UserImplementedGenericMethodMapping? TryCreateConcreteMapping(
46+
GenericTypeChecker checker,
47+
ITypeSymbol concreteSourceType,
48+
ITypeSymbol concreteTargetType
49+
)
50+
{
51+
var result = checker.InferAndCheckTypes(
52+
method.TypeParameters,
53+
(sourceParameter.Type, concreteSourceType),
54+
(genericTargetType, concreteTargetType)
55+
);
56+
57+
if (!result.Success)
58+
return null;
59+
60+
var typeArguments = method.TypeParameters.Select(tp => result.InferredTypes[tp]).ToList();
61+
62+
return new UserImplementedGenericMethodMapping(
63+
receiver,
64+
method,
65+
null,
66+
sourceParameter,
67+
concreteSourceType,
68+
concreteTargetType,
69+
typeArguments,
70+
referenceHandlerParameter,
71+
isExternal,
72+
targetNullability
73+
);
74+
}
75+
}

src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
23
using Microsoft.CodeAnalysis.CSharp.Syntax;
34
using Riok.Mapperly.Descriptors.Mappings.ExistingTarget;
45
using Riok.Mapperly.Symbols;
@@ -16,9 +17,11 @@ public class UserImplementedExistingTargetMethodMapping(
1617
bool? isDefault,
1718
MethodParameter sourceParameter,
1819
MethodParameter targetParameter,
20+
ITypeSymbol sourceType,
21+
ITypeSymbol targetType,
1922
MethodParameter? referenceHandlerParameter,
2023
bool isExternal
21-
) : ExistingTargetMapping(method.Parameters[0].Type, targetParameter.Type), IExistingTargetUserMapping, IParameterizedMapping
24+
) : ExistingTargetMapping(sourceType, targetType), IExistingTargetUserMapping, IParameterizedMapping
2225
{
2326
public IMethodSymbol Method { get; } = method;
2427

@@ -38,6 +41,8 @@ bool isExternal
3841

3942
public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx, ExpressionSyntax target)
4043
{
44+
var methodName = BuildMethodName();
45+
4146
// if the user implemented method is on an interface,
4247
// we explicitly cast to be able to use the default interface implementation or explicit implementations
4348
if (Method.ReceiverType?.TypeKind != TypeKind.Interface)
@@ -49,7 +54,7 @@ public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx,
4954
yield return ctx.SyntaxFactory.DeclareLocalVariable(targetRefVarName, target);
5055
yield return ctx.SyntaxFactory.ExpressionStatement(
5156
ctx.SyntaxFactory.Invocation(
52-
receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name),
57+
GetMethodExpr(),
5358
ctx.BuildArguments(
5459
Method,
5560
sourceParameter,
@@ -65,23 +70,34 @@ public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx,
6570

6671
yield return ctx.SyntaxFactory.ExpressionStatement(
6772
ctx.SyntaxFactory.Invocation(
68-
receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name),
73+
GetMethodExpr(),
6974
ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target))
7075
)
7176
);
7277
yield break;
78+
79+
ExpressionSyntax GetMethodExpr() =>
80+
receiver == null
81+
? methodName
82+
: MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName(receiver), methodName);
7383
}
7484

7585
var castedThis = CastExpression(
7686
FullyQualifiedIdentifier(Method.ReceiverType!),
7787
receiver != null ? IdentifierName(receiver) : ThisExpression()
7888
);
79-
var methodExpr = MemberAccess(ParenthesizedExpression(castedThis), Method.Name);
89+
var castedMethodExpr = MemberAccessExpression(
90+
SyntaxKind.SimpleMemberAccessExpression,
91+
ParenthesizedExpression(castedThis),
92+
methodName
93+
);
8094
yield return ctx.SyntaxFactory.ExpressionStatement(
8195
ctx.SyntaxFactory.Invocation(
82-
methodExpr,
96+
castedMethodExpr,
8397
ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target))
8498
)
8599
);
86100
}
101+
102+
protected virtual SimpleNameSyntax BuildMethodName() => IdentifierName(Method.Name);
87103
}

0 commit comments

Comments
 (0)