Skip to content

Commit

Permalink
Add support for From and To method conversions (#1117) (#1616)
Browse files Browse the repository at this point in the history
  • Loading branch information
TonEnfer authored Dec 9, 2024
1 parent 84e374d commit 39376fa
Show file tree
Hide file tree
Showing 37 changed files with 1,387 additions and 129 deletions.
42 changes: 22 additions & 20 deletions docs/docs/configuration/conversions.md

Large diffs are not rendered by default.

31 changes: 25 additions & 6 deletions src/Riok.Mapperly.Abstractions/MappingConversionType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public enum MappingConversionType

/// <summary>
/// If the source type is a <see cref="string"/>,
/// uses a a static visible method named `Parse` on the target type
/// uses a static visible method named `Parse` on the target type
/// with a return type equal to the target type and a string as single parameter.
/// </summary>
ParseMethod = 1 << 3,
Expand Down Expand Up @@ -64,26 +64,26 @@ public enum MappingConversionType

/// <summary>
/// If the source is a <see cref="DateTime"/>
/// and the target is a DateOnly
/// and the target is a <c>DateOnly</c>
/// uses the `FromDateTime` method on the target type with the source as single parameter.
/// </summary>
DateTimeToDateOnly = 1 << 8,

/// <summary>
/// If the source is a <see cref="DateTime"/>
/// and the target is a TimeOnly
/// and the target is a <c>TimeOnly</c>
/// uses the `FromDateTime` method on the target type with the source as single parameter.
/// </summary>
DateTimeToTimeOnly = 1 << 9,

/// <summary>
/// If the source and the target is a <see cref="IQueryable{T}"/>.
/// If the source and the target are a <see cref="IQueryable{T}"/>.
/// Only uses object initializers and inlines the mapping code.
/// </summary>
Queryable = 1 << 10,

/// <summary>
/// If the source and the target is an <see cref="IEnumerable{T}"/>
/// If the source and the target are an <see cref="IEnumerable{T}"/>
/// Maps each element individually.
/// </summary>
Enumerable = 1 << 11,
Expand Down Expand Up @@ -115,10 +115,29 @@ public enum MappingConversionType
Tuple = 1 << 15,

/// <summary>
/// Allow using the underlying type of an enum to map from or to an enum type.
/// Allow using the underlying type of enum to map from or to an enum type.
/// </summary>
EnumUnderlyingType = 1 << 16,

/// <summary>
/// If the source type contains a `ToTarget` method other than `ToString`, use it
/// </summary>
ToTargetMethod = 1 << 17,

/// <summary>
/// If the source type contains a static `ToTarget` method
/// or the target type contains a static methods
/// `Create(TSource)`,
/// `CreateFrom(TSource)`,
/// `CreateFromTSource(TSource)`,
/// `From(TSource)`,
/// `FromTSource(TSource)`
/// or similar methods with <see langword="params"/> keyword, use it.
/// The exception is <see cref="DateTime"/> conversions,
/// which are enabled by separate options (<seealso cref="DateTimeToTimeOnly"/>, <seealso cref="DateTimeToDateOnly"/>).
/// </summary>
StaticConvertMethods = 1 << 18,

/// <summary>
/// Enables all supported conversions.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Descriptors.MappingBuilders;

public static class ConvertInstanceMethodMappingBuilder
{
public static SourceObjectMethodMapping? TryBuildMapping(MappingBuilderContext ctx)
{
if (!ctx.IsConversionEnabled(MappingConversionType.ToTargetMethod))
return null;

var targetIsNullable = ctx.Target.NonNullable(out var nonNullableTarget);

// ignore `ToString` mapping for backward compatibility
if (nonNullableTarget.SpecialType == SpecialType.System_String)
return null;

foreach (var methodName in GetMappingMethodNames(ctx))
{
var methodCandidates = ctx
.SymbolAccessor.GetAllMethods(ctx.Source)
.Where(m =>
string.Equals(m.Name, methodName, StringComparison.OrdinalIgnoreCase)
&& m is { IsStatic: false, ReturnsVoid: false, IsAsync: false, Parameters.Length: 0 }
)
.ToList();

// try to find method with equal nullability return type
var method = methodCandidates.Find(x => SymbolEqualityComparer.IncludeNullability.Equals(x.ReturnType, ctx.Target));
if (method is not null)
return new SourceObjectMethodMapping(ctx.Source, ctx.Target, method.Name);

if (!targetIsNullable)
continue;

// otherwise try to find method ignoring the nullability
method = methodCandidates.Find(x => SymbolEqualityComparer.Default.Equals(x.ReturnType, nonNullableTarget));

if (method is null)
continue;

return new SourceObjectMethodMapping(ctx.Source, ctx.Target, method.Name);
}

return null;
}

private static IEnumerable<string> GetMappingMethodNames(MappingBuilderContext ctx)
{
var nonNullableTarget = ctx.Target.NonNullable();
var hasKeyword = nonNullableTarget.HasKeyword(out var keywordName);
if (!nonNullableTarget.IsArrayType(out var arrayType))
{
var methodName = $"To{nonNullableTarget.Name}";
return hasKeyword ? [methodName, $"To{keywordName}"] : [methodName];
}

var nonNullableElementType = arrayType.ElementType.NonNullable();

var arrayMethodName = $"To{nonNullableElementType.Name}Array";
return hasKeyword ? [arrayMethodName, $"To{keywordName}Array"] : [arrayMethodName];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Descriptors.MappingBuilders;

public static class ConvertStaticMethodMappingBuilder
{
public static StaticMethodMapping? TryBuildMapping(MappingBuilderContext ctx)
{
if (!IsConversionEnabled(ctx))
return null;

var targetIsNullable = ctx.Target.NonNullable(out var nonNullableTarget);

var allTargetMethods = ctx.SymbolAccessor.GetAllMethods(nonNullableTarget).ToList();

var mapping = TryGetStaticMethodMapping(
ctx.SymbolAccessor,
allTargetMethods,
GetTargetStaticMethodNames(ctx),
ctx.Source,
ctx.Target,
nonNullableTarget,
targetIsNullable
);

if (mapping is not null)
{
return mapping;
}

var allSourceMethods = ctx.SymbolAccessor.GetAllMethods(ctx.Source);

// collect also methods from source type generic argument, for example `TTarget ToTarget(List<A> source)`
if (ctx.Source is INamedTypeSymbol { TypeArguments.Length: 1 } namedTypeSymbol)
{
allSourceMethods = allSourceMethods.Concat(ctx.SymbolAccessor.GetAllMethods(namedTypeSymbol.TypeArguments[0]));
}

return TryGetStaticMethodMapping(
ctx.SymbolAccessor,
allSourceMethods.ToList(),
GetSourceStaticMethodNames(ctx),
ctx.Source,
ctx.Target,
nonNullableTarget,
targetIsNullable
);
}

private static bool IsConversionEnabled(MappingBuilderContext ctx)
{
//checks DateTime type for backward compatibility
return ctx.IsConversionEnabled(
IsDateTimeToDateOnlyConversion(ctx) ? MappingConversionType.DateTimeToDateOnly
: IsDateTimeToTimeOnlyConversion(ctx) ? MappingConversionType.DateTimeToTimeOnly
: MappingConversionType.StaticConvertMethods
);
}

private static bool IsDateTimeToDateOnlyConversion(MappingBuilderContext ctx)
{
return ctx.Source.SpecialType == SpecialType.System_DateTime
&& ctx.Types.DateOnly != null
&& ctx.Target is INamedTypeSymbol namedSymbol
&& SymbolEqualityComparer.Default.Equals(namedSymbol, ctx.Types.DateOnly);
}

private static bool IsDateTimeToTimeOnlyConversion(MappingBuilderContext ctx)
{
return ctx.Source.SpecialType == SpecialType.System_DateTime
&& ctx.Types.TimeOnly != null
&& ctx.Target is INamedTypeSymbol namedSymbol
&& SymbolEqualityComparer.Default.Equals(namedSymbol, ctx.Types.TimeOnly);
}

private static StaticMethodMapping? TryGetStaticMethodMapping(
SymbolAccessor symbolAccessor,
List<IMethodSymbol> allMethods,
IEnumerable<string> methodNames,
ITypeSymbol sourceType,
ITypeSymbol targetType,
ITypeSymbol nonNullableTargetType,
bool targetIsNullable
)
{
// Get all methods with a single parameter whose type is suitable for assignment to the source type, group them by name,
// and convert them to a dictionary whose key is the method name.
// The keys in the dictionary are compared case-insensitively to handle possible `Uint` vs `UInt` cases, etc.
var allMethodCandidates = allMethods
.Where(m => m is { IsStatic: true, ReturnsVoid: false, IsAsync: false, Parameters.Length: 1 })
.GroupBy(x => x.Name, x => x, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);

foreach (var methodName in methodNames)
{
if (!allMethodCandidates.TryGetValue(methodName, out var candidates))
continue;

if (targetIsNullable)
{
continue;
}

var method = candidates.Find(x => symbolAccessor.ValidateSignature(x, nonNullableTargetType, sourceType));

if (method != null)
return new StaticMethodMapping(method);
}

return null;
}

private static IEnumerable<string> GetTargetStaticMethodNames(MappingBuilderContext ctx)
{
if (ctx.Source.IsArrayType(out var arrayType))
{
yield return $"CreateFrom{arrayType.ElementType.Name}Array";
yield return $"From{arrayType.ElementType.Name}Array";

if (!arrayType.ElementType.HasKeyword(out var keywordName))
yield break;

yield return $"CreateFrom{keywordName}Array";

yield return $"From{keywordName}Array";

yield return "FromArray";
yield return "CreateFromArray";
yield return "CreateFrom";
yield return "Create";
yield break;
}

yield return $"CreateFrom{ctx.Source.Name}";
yield return $"From{ctx.Source.Name}";

if (ctx.Source.HasKeyword(out var sourceKeyword))
{
yield return $"CreateFrom{sourceKeyword}";
yield return $"From{sourceKeyword}";
}

yield return "CreateFrom";
yield return "Create";
}

private static IEnumerable<string> GetSourceStaticMethodNames(MappingBuilderContext ctx)
{
var nonNullableTarget = ctx.Target.NonNullable();

yield return $"To{nonNullableTarget.Name}";

if (nonNullableTarget.HasKeyword(out var keywordName))
yield return $"To{keywordName}";

if (!nonNullableTarget.IsArrayType(out var arrayTypeSymbol))
yield break;

var nonNullableElementType = arrayTypeSymbol.ElementType.NonNullable();

yield return $"To{nonNullableElementType.Name}Array";

if (nonNullableElementType.HasKeyword(out var elementTypeName))
yield return $"To{elementTypeName}Array";
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ public class MappingBuilder(MappingCollection mappings, MapperDeclaration mapper
StringToEnumMappingBuilder.TryBuildMapping,
EnumToStringMappingBuilder.TryBuildMapping,
EnumToEnumMappingBuilder.TryBuildMapping,
DateTimeToDateOnlyMappingBuilder.TryBuildMapping,
DateTimeToTimeOnlyMappingBuilder.TryBuildMapping,
ExplicitCastMappingBuilder.TryBuildMapping,
ToStringMappingBuilder.TryBuildMapping,
ConvertInstanceMethodMappingBuilder.TryBuildMapping,
ConvertStaticMethodMappingBuilder.TryBuildMapping,
NewInstanceObjectMemberMappingBuilder.TryBuildMapping,
];

Expand Down
Loading

0 comments on commit 39376fa

Please sign in to comment.