Skip to content

Commit

Permalink
feat: accept destination type as mapping method parameter (#398)
Browse files Browse the repository at this point in the history
  • Loading branch information
latonz committed May 10, 2023
1 parent ebff0af commit b17f666
Show file tree
Hide file tree
Showing 44 changed files with 1,004 additions and 158 deletions.
Expand Up @@ -8,7 +8,7 @@ import GeneratedCarMapperSource from '!!raw-loader!../../src/data/generated/samp

This example will show you what kind of code Mapperly generates.
It is based on the [Mapperly sample](https://github.com/riok/mapperly/tree/main/samples/Riok.Mapperly.Sample).
To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/15-generated-source.mdx).
To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/16-generated-source.mdx).

## The source classes

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/02-configuration/01-mapper.md
Expand Up @@ -84,7 +84,7 @@ To enforce strict mappings
(all source members have to be mapped to a target member
and all target members have to be mapped from a source member,
except for ignored members)
set the following two EditorConfig settings (see also [analyzer diagnostics](./14-analyzer-diagnostics.mdx)):
set the following two EditorConfig settings (see also [analyzer diagnostics](./15-analyzer-diagnostics.mdx)):

```editorconfig title=".editorconfig"
[*.cs]
Expand All @@ -94,4 +94,4 @@ dotnet_diagnostic.RMG020.severity = error # Unmapped source member

### Strict enum mappings

To enforce strict enum mappings set 'RMG037' and 'RMG038' to error, see [strict enum mappings](./04-enum.mdx).
To enforce strict enum mappings set `RMG037` and `RMG038` to error, see [strict enum mappings](./04-enum.mdx).
2 changes: 1 addition & 1 deletion docs/docs/02-configuration/04-enum.mdx
Expand Up @@ -57,7 +57,7 @@ public partial class CarMapper
To enforce strict enum mappings
(all source enum values have to be mapped to a target enum value
and all target enum values have to be mapped from a source enum value)
set the following two EditorConfig settings (see also [analyzer diagnostics](./14-analyzer-diagnostics.mdx)):
set the following two EditorConfig settings (see also [analyzer diagnostics](./15-analyzer-diagnostics.mdx)):

```editorconfig title=".editorconfig"
[*.cs]
Expand Down
28 changes: 14 additions & 14 deletions docs/docs/02-configuration/10-derived-type-mapping.md
Expand Up @@ -17,19 +17,19 @@ This can be configured with the `MapDerivedTypeAttribute`:
public static partial class ModelMapper
{
// highlight-start
[MapDerivedType<Audi, AudiDto>] // for c# language level ≥ 11
[MapDerivedType(typeof(Porsche), typeof(PorscheDto))] // for c# language level < 11
[MapDerivedType<Banana, BananaDto>] // for c# language level ≥ 11
[MapDerivedType(typeof(Apple), typeof(AppleDto))] // for c# language level < 11
// highlight-end
public static partial CarDto MapCar(Car source);
public static partial FruitDto MapFruit(Fruit source);
}

abstract class Car {}
class Audi : Car {}
class Porsche : Car {}
abstract class Fruit {}
class Banana : Fruit {}
class Apple : Fruit {}

abstract class CarDto {}
class AudiDto : CarDto {}
class PorscheDto : CarDto {}
abstract class FruitDto {}
class BananaDto : FruitDto {}
class AppleDto : FruitDto {}
```

</TabItem>
Expand All @@ -39,17 +39,17 @@ class PorscheDto : CarDto {}
[Mapper]
public static partial class ModelMapper
{
public static partial CarDto MapCar(Car source)
public static partial FruitDto MapFruit(Fruit source)
{
return source switch
{
Audi x => MapToAudiDto(x),
Porsche x => MapToPorscheDto(x),
_ => throw new System.ArgumentException($"Cannot map {source.GetType()} to CarDto as there is no known derived type mapping", nameof(source)),
Banana x => MapToBananaDto(x),
Apple x => MapToAppleDto(x),
_ => throw new System.ArgumentException($"Cannot map {source.GetType()} to FruitDto as there is no known derived type mapping", nameof(source)),
};
}

// ... implementations of MapToAudiDto and MapToPorscheDto
// ... implementations of MapToBananaDto and MapToAppleDto
}
```

Expand Down
39 changes: 39 additions & 0 deletions docs/docs/02-configuration/11-runtime-target-type-mapping.md
@@ -0,0 +1,39 @@
# Runtime target type mapping

If the target type of a mapping is not known at compile time,
a mapping method with a `Type` parameter can be used.
Mapperly implements this mapping method
using all mappings the user defined in the mapper.

```csharp
[Mapper]
public static partial class ModelMapper
{
// highlight-start
public static partial object Map(object source, Type targetType);
// highlight-end
private static partial BananaDto MapBanana(Banana source);
private static partial AppleDto MapApple(Apple source);
}

class Banana {}
class Apple {}

class BananaDto {}
class AppleDto {}
```

If the source or target type of a runtime target type mapping is not `object`,
only user mappings of which the source/target type is assignable to the source/target type of the mapping method are considered.

Runtime target type mappings support [derived type mappings](./10-derived-type-mapping.md).
The `MapDerivedTypeAttribute` can be directly applied to a runtime target type mapping method.

:::info
Mapperly runtime target type mappings
only support source/target type combinations which are defined
as mappings in the same mapper.
If an unknown source/target type combination is provided at runtime,
an `ArgumentException` is thrown.
:::
Expand Up @@ -37,6 +37,9 @@ public void BuildMappingBodies()
case UserDefinedExistingTargetMethodMapping mapping:
UserMethodMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
case UserDefinedNewInstanceRuntimeTargetTypeMapping mapping:
RuntimeTargetTypeMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
}
}
}
Expand Down
@@ -0,0 +1,40 @@
using Riok.Mapperly.Descriptors.MappingBuilders;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders;

public static class RuntimeTargetTypeMappingBodyBuilder
{
public static void BuildMappingBody(MappingBuilderContext ctx, UserDefinedNewInstanceRuntimeTargetTypeMapping mapping)
{
// source nulls are filtered out by the type switch arms,
// therefore set source type always to nun-nullable
// as non-nullables are also assignable to nullables.
IEnumerable<ITypeMapping> mappings = ctx.CallableUserMappings.Where(
x =>
x.SourceType.NonNullable().IsAssignableTo(ctx.Compilation, mapping.SourceType)
&& x.TargetType.IsAssignableTo(ctx.Compilation, mapping.TargetType)
);

// include derived type mappings declared on this user defined method
var derivedTypeMappings = DerivedTypeMappingBuilder.TryBuildContainedMappings(ctx, true);
if (derivedTypeMappings != null)
{
mappings = derivedTypeMappings.Concat(mappings);
}

// prefer non-nullable return types
// and prefer types with a higher inheritance level
// over types with a lower inheritance level
// in the type switch
// to use the most specific mapping
mappings = mappings
.OrderByDescending(x => x.SourceType.GetInheritanceLevel())
.ThenByDescending(x => x.TargetType.GetInheritanceLevel())
.ThenBy(x => x.TargetType.IsNullable())
.GroupBy(x => new TypeMappingKey(x, false))
.Select(x => x.First());
mapping.AddMappings(mappings);
}
}
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
Expand Up @@ -42,6 +42,9 @@ protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSy

public ObjectFactoryCollection ObjectFactories { get; }

/// <inheritdoc cref="MappingBuilderContext.CallableUserMappings"/>
public IReadOnlyCollection<IUserMapping> CallableUserMappings => MappingBuilder.CallableUserMappings;

public T GetConfigurationOrDefault<T>()
where T : Attribute => Configuration.GetOrDefault<T>(_userSymbol);

Expand Down
Expand Up @@ -11,31 +11,40 @@ public static class DerivedTypeMappingBuilder
{
public static ITypeMapping? TryBuildMapping(MappingBuilderContext ctx)
{
var configs = ctx.ListConfiguration<MapDerivedTypeAttribute, MapDerivedType>()
.Concat(ctx.ListConfiguration<MapDerivedTypeAttribute<object, object>, MapDerivedType>())
.ToList();
if (configs.Count == 0)
var derivedTypeMappings = TryBuildContainedMappings(ctx);
if (derivedTypeMappings == null)
return null;

var derivedTypeMappings = BuildDerivedTypeMappings(ctx, configs);
return ctx.IsExpression
? new DerivedTypeIfExpressionMapping(ctx.Source, ctx.Target, derivedTypeMappings)
: new DerivedTypeSwitchMapping(ctx.Source, ctx.Target, derivedTypeMappings);
}

private static IReadOnlyCollection<ITypeMapping> BuildDerivedTypeMappings(
public static IReadOnlyCollection<ITypeMapping>? TryBuildContainedMappings(
MappingBuilderContext ctx,
bool duplicatedSourceTypesAllowed = false
)
{
var configs = ctx.ListConfiguration<MapDerivedTypeAttribute, MapDerivedType>()
.Concat(ctx.ListConfiguration<MapDerivedTypeAttribute<object, object>, MapDerivedType>())
.ToList();
return configs.Count == 0 ? null : BuildContainedMappings(ctx, configs, duplicatedSourceTypesAllowed);
}

private static IReadOnlyCollection<ITypeMapping> BuildContainedMappings(
MappingBuilderContext ctx,
IReadOnlyCollection<MapDerivedType> configs
IReadOnlyCollection<MapDerivedType> configs,
bool duplicatedSourceTypesAllowed
)
{
var derivedTypeMappingSourceTypes = new HashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
var derivedTypeMappings = new List<ITypeMapping>(configs.Count);

foreach (var config in configs)
{
// set reference types non-nullable as they can never be null when type-switching.
var sourceType = config.SourceType.WithNullableAnnotation(NullableAnnotation.NotAnnotated);
if (!derivedTypeMappingSourceTypes.Add(sourceType))
// set types non-nullable as they can never be null when type-switching.
var sourceType = config.SourceType.NonNullable();
if (!duplicatedSourceTypesAllowed && !derivedTypeMappingSourceTypes.Add(sourceType))
{
ctx.ReportDiagnostic(DiagnosticDescriptors.DerivedSourceTypeDuplicated, sourceType);
continue;
Expand All @@ -47,7 +56,7 @@ IReadOnlyCollection<MapDerivedType> configs
continue;
}

var targetType = config.TargetType.WithNullableAnnotation(NullableAnnotation.NotAnnotated);
var targetType = config.TargetType.NonNullable();
if (!targetType.IsAssignableTo(ctx.Compilation, ctx.Target))
{
ctx.ReportDiagnostic(DiagnosticDescriptors.DerivedTargetTypeIsNotAssignableToReturnType, targetType, ctx.Target);
Expand Down
Expand Up @@ -36,6 +36,9 @@ public MappingBuilder(MappingCollection mappings)
_mappings = mappings;
}

/// <inheritdoc cref="MappingCollection.CallableUserMappings"/>
public IReadOnlyCollection<IUserMapping> CallableUserMappings => _mappings.CallableUserMappings;

/// <inheritdoc cref="MappingBuilderContext.FindMapping"/>
public ITypeMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) => _mappings.Find(sourceType, targetType);

Expand Down
68 changes: 26 additions & 42 deletions src/Riok.Mapperly/Descriptors/MappingCollection.cs
Expand Up @@ -7,20 +7,37 @@ namespace Riok.Mapperly.Descriptors;

public class MappingCollection
{
// this includes mappings to build and already built mappings
/// <summary>
/// The first callable mapping of each type pair.
/// Contains mappings to build and already built mappings
/// </summary>
private readonly Dictionary<TypeMappingKey, ITypeMapping> _mappings = new();

// a list of all method mappings (extra mappings and mappings)
/// <summary>
/// A list of all method mappings (extra mappings and mappings)
/// </summary>
private readonly List<MethodMapping> _methodMappings = new();

// queue of mappings which don't have the body built yet
/// <summary>
/// A list of all callable user mappings with <see cref="ITypeMapping.CallableByOtherMappings"/> <c>true</c>.
/// </summary>
private readonly List<IUserMapping> _callableUserMappings = new();

/// <summary>
/// Queue of mappings which don't have the body built yet
/// </summary>
private readonly Queue<(IMapping, MappingBuilderContext)> _mappingsToBuildBody = new();

// a list of existing target mappings
/// <summary>
/// All existing target mappings
/// </summary>
private readonly Dictionary<TypeMappingKey, IExistingTargetMapping> _existingTargetMappings = new();

public IReadOnlyCollection<MethodMapping> MethodMappings => _methodMappings;

/// <inheritdoc cref="_callableUserMappings"/>
public IReadOnlyCollection<IUserMapping> CallableUserMappings => _callableUserMappings;

public ITypeMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType)
{
_mappings.TryGetValue(new TypeMappingKey(sourceType, targetType), out var mapping);
Expand All @@ -37,6 +54,11 @@ public class MappingCollection

public void Add(ITypeMapping mapping)
{
if (mapping is IUserMapping { CallableByOtherMappings: true } userMapping)
{
_callableUserMappings.Add(userMapping);
}

if (mapping is MethodMapping methodMapping)
{
_methodMappings.Add(methodMapping);
Expand All @@ -52,42 +74,4 @@ public void Add(ITypeMapping mapping)
_existingTargetMappings.Add(new TypeMappingKey(mapping), mapping);

public IEnumerable<(IMapping, MappingBuilderContext)> DequeueMappingsToBuildBody() => _mappingsToBuildBody.DequeueAll();

private readonly struct TypeMappingKey
{
private static readonly IEqualityComparer<ISymbol?> _comparer = SymbolEqualityComparer.IncludeNullability;

private readonly ITypeSymbol _source;
private readonly ITypeSymbol _target;

public TypeMappingKey(ITypeMapping mapping)
: this(mapping.SourceType, mapping.TargetType) { }

public TypeMappingKey(IExistingTargetMapping mapping)
: this(mapping.SourceType, mapping.TargetType) { }

public TypeMappingKey(ITypeSymbol source, ITypeSymbol target)
{
_source = source;
_target = target;
}

private bool Equals(TypeMappingKey other) => _comparer.Equals(_source, other._source) && _comparer.Equals(_target, other._target);

public override bool Equals(object? obj) => obj is TypeMappingKey other && Equals(other);

public override int GetHashCode()
{
unchecked
{
var hashCode = _comparer.GetHashCode(_source);
hashCode = (hashCode * 397) ^ _comparer.GetHashCode(_target);
return hashCode;
}
}

public static bool operator ==(TypeMappingKey left, TypeMappingKey right) => left.Equals(right);

public static bool operator !=(TypeMappingKey left, TypeMappingKey right) => !left.Equals(right);
}
}
Expand Up @@ -11,7 +11,7 @@ namespace Riok.Mapperly.Descriptors.Mappings;
/// </summary>
public class DerivedTypeSwitchMapping : TypeMapping
{
private const string GetTypeMethodName = "GetType";
private const string GetTypeMethodName = nameof(GetType);

private readonly IReadOnlyCollection<ITypeMapping> _typeMappings;

Expand All @@ -33,7 +33,7 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx)
)
);

// source switch { A x => MapToA(x), B x => MapToB(x) }
// source switch { A x => MapToADto(x), B x => MapToBDto(x) }
var (typeArmContext, typeArmVariableName) = ctx.WithNewSource();
var arms = _typeMappings
.Select(x => BuildSwitchArm(typeArmVariableName, x.SourceType, x.Build(typeArmContext)))
Expand All @@ -43,7 +43,7 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx)

private SwitchExpressionArmSyntax BuildSwitchArm(string typeArmVariableName, ITypeSymbol type, ExpressionSyntax mapping)
{
// A x => MapToA(x),
// A x => MapToADto(x),
var declaration = DeclarationPattern(FullyQualifiedIdentifier(type), SingleVariableDesignation(Identifier(typeArmVariableName)));
return SwitchExpressionArm(declaration, mapping);
}
Expand Down

0 comments on commit b17f666

Please sign in to comment.