Skip to content
Open
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
43 changes: 43 additions & 0 deletions docs/docs/configuration/user-implemented-methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,46 @@ The mapper will respect the `ref` keyword on the target parameter when using an
private static void MapArray([MappingTarget] ref int[] target, int[] second) => target = [.. target, .. second.Except(target)];
}
```

## Generic user-implemented mapping methods

User-implemented mapping methods can be generic.
Mapperly automatically resolves generic user-implemented mapping methods
by inferring type arguments from the source and target types.

```csharp
public record Document(string Title, User CreatedBy, Optional<User> ModifiedBy);
public record DocumentDto(string Title, UserDto CreatedBy, Optional<UserDto> ModifiedBy);

public record User(string Name);
public record UserDto(string Name);

[Mapper]
public partial class MyMapper
{
public partial DocumentDto MapDocument(Document source);

// highlight-start
private Optional<TTarget> MapOptional<TSource, TTarget>(Optional<TSource> source)
where TSource : notnull
where TTarget : notnull
=> source.HasValue
? Optional.Of(Map(source.Value))
: Optional.Empty();
// highlight-end

private partial TTarget Map<TSource, TTarget>(TSource source)
where TSource : notnull
where TTarget : notnull;

private partial UserDto MapUser(User source);
}
```

In this example, when Mapperly needs to map `Optional<User>` to `Optional<UserDto>`,
it will match the `MapOptional` method and invoke it as `MapOptional<User, UserDto>(source.ModifiedBy)`.

:::info
If a non-generic user-implemented mapping method exists for an exact type pair,
it takes priority over a generic one.
:::
1 change: 1 addition & 0 deletions src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ ImmutableArray<UseStaticMapperConfiguration> assemblyScopedStaticMappers
_diagnostics = new DiagnosticCollection(mapperDeclaration.Syntax.GetLocation());

var genericTypeChecker = new GenericTypeChecker(_symbolAccessor, compilationContext.Types);
_mappings.SetGenericTypeChecker(genericTypeChecker);
var configurationReader = new MapperConfigurationReader(
_attributeAccessor,
_mappings,
Expand Down
68 changes: 66 additions & 2 deletions src/Riok.Mapperly/Descriptors/MappingCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ public class MappingCollection
/// </summary>
private readonly MappingCollectionInstance<IExistingTargetMapping, IExistingTargetUserMapping> _existingTargetMappings = new();

/// <summary>
/// Generic user-implemented new instance mapping templates.
/// These are matched against concrete type pairs during <see cref="FindNewInstanceMapping"/>.
/// </summary>
private readonly List<GenericUserImplementedNewInstanceMethodMapping> _genericNewInstanceTemplates = [];

/// <summary>
/// Generic user-implemented existing target mapping templates.
/// These are matched against concrete type pairs during <see cref="FindExistingInstanceMapping"/>.
/// </summary>
private readonly List<GenericUserImplementedExistingTargetMethodMapping> _genericExistingTargetTemplates = [];

private GenericTypeChecker? _genericTypeChecker;

/// <inheritdoc cref="_methodMappings"/>
public IReadOnlyCollection<MethodMapping> MethodMappings => _methodMappings;

Expand All @@ -62,15 +76,18 @@ public class MappingCollection
.Concat(_newInstanceMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings)
.Concat(_existingTargetMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings);

public void SetGenericTypeChecker(GenericTypeChecker genericTypeChecker) => _genericTypeChecker = genericTypeChecker;

public INewInstanceMapping? FindNewInstanceMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) =>
_newInstanceMappings.Find(mappingKey, scope);
_newInstanceMappings.Find(mappingKey, scope) ?? TryMatchGenericNewInstanceTemplate(mappingKey);

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

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

public IExistingTargetMapping? FindExistingInstanceMapping(TypeMappingKey mappingKey) => _existingTargetMappings.Find(mappingKey);
public IExistingTargetMapping? FindExistingInstanceMapping(TypeMappingKey mappingKey) =>
_existingTargetMappings.Find(mappingKey) ?? TryMatchGenericExistingTargetTemplate(mappingKey);

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

// Generic user-implemented mapping templates are stored separately
// and matched lazily during Find operations.
if (userMapping is GenericUserImplementedNewInstanceMethodMapping genericNewInstance)
{
_genericNewInstanceTemplates.Add(genericNewInstance);
}
else if (userMapping is GenericUserImplementedExistingTargetMethodMapping genericExistingTarget)
{
_genericExistingTargetTemplates.Add(genericExistingTarget);
}

return userMapping switch
{
INewInstanceUserMapping newInstanceMapping => _newInstanceMappings.AddUserMapping(
Expand Down Expand Up @@ -158,6 +186,42 @@ public void AddNamedExistingInstanceUserMapping(string name, IExistingTargetUser
_existingTargetMappings.AddNamedUserMapping(name, mapping);
}

private INewInstanceMapping? TryMatchGenericNewInstanceTemplate(TypeMappingKey mappingKey)
{
if (_genericTypeChecker == null || _genericNewInstanceTemplates.Count == 0)
return null;

foreach (var template in _genericNewInstanceTemplates)
{
if (template.TryCreateConcreteMapping(_genericTypeChecker, mappingKey.Source, mappingKey.Target) is not { } mapping)
continue;

// Cache the concrete mapping so subsequent lookups find it directly.
_newInstanceMappings.TryAddAsDefault(mapping, mappingKey.Configuration);
return mapping;
}

return null;
}

private IExistingTargetMapping? TryMatchGenericExistingTargetTemplate(TypeMappingKey mappingKey)
{
if (_genericTypeChecker == null || _genericExistingTargetTemplates.Count == 0)
return null;

foreach (var template in _genericExistingTargetTemplates)
{
if (template.TryCreateConcreteMapping(_genericTypeChecker, mappingKey.Source, mappingKey.Target) is not { } mapping)
continue;

// Cache the concrete mapping so subsequent lookups find it directly.
_existingTargetMappings.TryAddAsDefault(mapping, mappingKey.Configuration);
return mapping;
}

return null;
}

private class MappingCollectionInstance<T, TUserMapping>
where T : ITypeMapping
where TUserMapping : T, IUserMapping
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols;

namespace Riok.Mapperly.Descriptors.Mappings.UserMappings;

/// <summary>
/// A template for a generic user-implemented existing target mapping method.
/// This is not a concrete mapping but stores the information needed to create
/// concrete instantiations when a matching type pair is found.
/// The <see cref="Default"/> is always <c>false</c> so it is not added to the default mappings dictionary.
/// </summary>
internal sealed class GenericUserImplementedExistingTargetMethodMapping(
string? receiver,
IMethodSymbol method,
MethodParameter sourceParameter,
MethodParameter targetParameter,
MethodParameter? referenceHandlerParameter,
bool isExternal
) : IExistingTargetUserMapping
{
public ITypeSymbol SourceType => sourceParameter.Type;

public ITypeSymbol TargetType => targetParameter.Type;

public IMethodSymbol Method => method;

public bool? Default => false;

public bool IsExternal => isExternal;

public bool IsSynthetic => false;

public IEnumerable<TypeMappingKey> BuildAdditionalMappingKeys(TypeMappingConfiguration config) => [];

public IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx, ExpressionSyntax target) =>
throw new InvalidOperationException("Generic mapping template should not be built directly");

/// <summary>
/// Tries to create a concrete mapping for the given source and target types
/// by inferring the type arguments from the generic method signature.
/// </summary>
public UserImplementedGenericExistingTargetMethodMapping? TryCreateConcreteMapping(
GenericTypeChecker checker,
ITypeSymbol concreteSourceType,
ITypeSymbol concreteTargetType
)
{
var result = checker.InferAndCheckTypes(
method.TypeParameters,
(sourceParameter.Type, concreteSourceType),
(targetParameter.Type, concreteTargetType)
);

if (!result.Success)
return null;

var typeArguments = method.TypeParameters.Select(tp => result.InferredTypes[tp]).ToList();

return new UserImplementedGenericExistingTargetMethodMapping(
receiver,
method,
null,
sourceParameter,
targetParameter,
concreteSourceType,
concreteTargetType,
typeArguments,
referenceHandlerParameter,
isExternal
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols;

namespace Riok.Mapperly.Descriptors.Mappings.UserMappings;

/// <summary>
/// A template for a generic user-implemented new instance mapping method.
/// This is not a concrete mapping but stores the information needed to create
/// concrete instantiations when a matching type pair is found.
/// The <see cref="Default"/> is always <c>false</c> so it is not added to the default mappings dictionary.
/// </summary>
internal sealed class GenericUserImplementedNewInstanceMethodMapping(
string? receiver,
IMethodSymbol method,
MethodParameter sourceParameter,
ITypeSymbol genericTargetType,
MethodParameter? referenceHandlerParameter,
bool isExternal,
UserImplementedMethodMapping.TargetNullability targetNullability
) : INewInstanceUserMapping
{
public ITypeSymbol SourceType => sourceParameter.Type;

public ITypeSymbol TargetType => genericTargetType;

public IMethodSymbol Method => method;

public bool? Default => false;

public bool IsExternal => isExternal;

public bool IsSynthetic => false;

public IEnumerable<TypeMappingKey> BuildAdditionalMappingKeys(TypeMappingConfiguration config) => [];

public ExpressionSyntax Build(TypeMappingBuildContext ctx) =>
throw new InvalidOperationException("Generic mapping template should not be built directly");

/// <summary>
/// Tries to create a concrete mapping for the given source and target types
/// by inferring the type arguments from the generic method signature.
/// </summary>
public UserImplementedGenericMethodMapping? TryCreateConcreteMapping(
GenericTypeChecker checker,
ITypeSymbol concreteSourceType,
ITypeSymbol concreteTargetType
)
{
var result = checker.InferAndCheckTypes(
method.TypeParameters,
(sourceParameter.Type, concreteSourceType),
(genericTargetType, concreteTargetType)
);

if (!result.Success)
return null;

var typeArguments = method.TypeParameters.Select(tp => result.InferredTypes[tp]).ToList();

return new UserImplementedGenericMethodMapping(
receiver,
method,
null,
sourceParameter,
concreteSourceType,
concreteTargetType,
typeArguments,
referenceHandlerParameter,
isExternal,
targetNullability
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors.Mappings.ExistingTarget;
using Riok.Mapperly.Symbols;
Expand All @@ -16,9 +17,11 @@ public class UserImplementedExistingTargetMethodMapping(
bool? isDefault,
MethodParameter sourceParameter,
MethodParameter targetParameter,
ITypeSymbol sourceType,
ITypeSymbol targetType,
MethodParameter? referenceHandlerParameter,
bool isExternal
) : ExistingTargetMapping(method.Parameters[0].Type, targetParameter.Type), IExistingTargetUserMapping, IParameterizedMapping
) : ExistingTargetMapping(sourceType, targetType), IExistingTargetUserMapping, IParameterizedMapping
{
public IMethodSymbol Method { get; } = method;

Expand All @@ -38,6 +41,8 @@ bool isExternal

public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx, ExpressionSyntax target)
{
var methodName = BuildMethodName();

// if the user implemented method is on an interface,
// we explicitly cast to be able to use the default interface implementation or explicit implementations
if (Method.ReceiverType?.TypeKind != TypeKind.Interface)
Expand All @@ -49,7 +54,7 @@ public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx,
yield return ctx.SyntaxFactory.DeclareLocalVariable(targetRefVarName, target);
yield return ctx.SyntaxFactory.ExpressionStatement(
ctx.SyntaxFactory.Invocation(
receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name),
GetMethodExpr(),
ctx.BuildArguments(
Method,
sourceParameter,
Expand All @@ -65,23 +70,34 @@ public override IEnumerable<StatementSyntax> Build(TypeMappingBuildContext ctx,

yield return ctx.SyntaxFactory.ExpressionStatement(
ctx.SyntaxFactory.Invocation(
receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name),
GetMethodExpr(),
ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target))
)
);
yield break;

ExpressionSyntax GetMethodExpr() =>
receiver == null
? methodName
: MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName(receiver), methodName);
}

var castedThis = CastExpression(
FullyQualifiedIdentifier(Method.ReceiverType!),
receiver != null ? IdentifierName(receiver) : ThisExpression()
);
var methodExpr = MemberAccess(ParenthesizedExpression(castedThis), Method.Name);
var castedMethodExpr = MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
ParenthesizedExpression(castedThis),
methodName
);
yield return ctx.SyntaxFactory.ExpressionStatement(
ctx.SyntaxFactory.Invocation(
methodExpr,
castedMethodExpr,
ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target))
)
);
}

protected virtual SimpleNameSyntax BuildMethodName() => IdentifierName(Method.Name);
}
Loading
Loading