Skip to content

Commit

Permalink
feat: Add option to disable automatic discovery of user implemented m…
Browse files Browse the repository at this point in the history
…apping method and to include/exclude specific user implemented mapping methods (#1070)

Add a boolean AutoUserMappings property to the AutoUserMappings whether to auto-discover user implemented mappings
Introduce a new attribute UserMappingAttribute with a boolean ignore property to ignore user implemented mappings
  • Loading branch information
latonz committed Feb 23, 2024
1 parent 8b14725 commit 4e4937c
Show file tree
Hide file tree
Showing 17 changed files with 327 additions and 6 deletions.
43 changes: 42 additions & 1 deletion docs/docs/configuration/user-implemented-methods.mdx
Expand Up @@ -22,6 +22,46 @@ public partial class CarMapper

Whenever Mapperly needs a mapping from `TimeSpan` to `int` inside the `CarMapper` implementation, it will use the provided implementation.

## Automatic user implemented mapping method discovery

By default, user implemented mapping methods are discovered automatically.
This can be disabled by setting `AutoUserMappings` to `false`.
With `AutoUserMappings` disabled, only methods marked by the `UserMappingAttribute` are discovered by Mapperly.
The `AutoUserMappings` value also applies to the usage of external mappers.

```csharp
[Mapper(AutoUserMappings = false)]
public partial class CarMapper
{
public partial CarDto CarToCarDto(Car car);

[UserMapping]
private int TimeSpanToHours(TimeSpan t) => t.Hours;

private int IgnoredTimeSpanToHours(TimeSpan t) => t.Minutes / 60;
}
```

## Ignoring a user-implemented mapping method

To ignore a user-implemented mapping method while using `AutoUserMappings`
`[UserMapping(Ignore = true)]` can be applied.

```csharp
[Mapper]
public partial class CarMapper
{
public partial CarDto CarToCarDto(Car car);

// discovered and used by default (since AutoUserMappings is true by default)
private int TimeSpanToHours(TimeSpan t) => t.Hours;

// ignored user-implemented mapping
[UserMapping(Ignore = true)]
private int IgnoredTimeSpanToHours(TimeSpan t) => t.Minutes / 60;
}
```

## Use external mappings

Mapperly can also consider mappings implemented in other classes.
Expand Down Expand Up @@ -83,4 +123,5 @@ In order for Mapperly to find the mappings, they must be made known with `UseMap
Whenever Mapperly needs a mapping from `BananaBox` to `BananaBoxDto` inside the `BoxMapper` implementation,
it will use the provided implementation by the `BananaMapper`.

Used mappers themselves can be Mapperly backed classes.
Used mappers themselves can be Mapperly backed classes.
The `AutoUserMappings` value also applies to the usage of external mappers.
5 changes: 5 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Expand Up @@ -106,4 +106,9 @@ public class MapperAttribute : Attribute
/// When <c>false</c>, accessible constructors are ordered in descending order by their parameter count.
/// </summary>
public bool PreferParameterlessConstructors { get; set; } = true;

/// <summary>
/// Whether to consider non-partial methods in a mapper as user implemented mapping methods.
/// </summary>
public bool AutoUserMappings { get; set; } = true;
}
6 changes: 6 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Expand Up @@ -143,3 +143,9 @@ Riok.Mapperly.Abstractions.ReferenceHandling.PreserveReferenceHandler.SetReferen
Riok.Mapperly.Abstractions.ReferenceHandling.PreserveReferenceHandler.TryGetReference<TSource, TTarget>(TSource source, out TTarget? target) -> bool
Riok.Mapperly.Abstractions.MapperAttribute.PreferParameterlessConstructors.get -> bool
Riok.Mapperly.Abstractions.MapperAttribute.PreferParameterlessConstructors.set -> void
Riok.Mapperly.Abstractions.MapperAttribute.AutoUserMappings.get -> bool
Riok.Mapperly.Abstractions.MapperAttribute.AutoUserMappings.set -> void
Riok.Mapperly.Abstractions.UserMappingAttribute
Riok.Mapperly.Abstractions.UserMappingAttribute.Ignore.get -> bool
Riok.Mapperly.Abstractions.UserMappingAttribute.Ignore.set -> void
Riok.Mapperly.Abstractions.UserMappingAttribute.UserMappingAttribute() -> void
18 changes: 18 additions & 0 deletions src/Riok.Mapperly.Abstractions/UserMappingAttribute.cs
@@ -0,0 +1,18 @@
using System.Diagnostics;

namespace Riok.Mapperly.Abstractions;

/// <summary>
/// A given method is marked as user implemented mapping with this attribute.
/// If <see cref="MapperAttribute.AutoUserMappings"/> is <c>true</c>,
/// this attribute allows to ignore a user implemented mapping method.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class UserMappingAttribute : Attribute
{
/// <summary>
/// Whether this user mapping should be ignored.
/// </summary>
public bool Ignore { get; set; }
}
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Expand Up @@ -20,6 +20,9 @@ public TAttribute AccessSingle<TAttribute>(ISymbol symbol)
where TAttribute : Attribute
where TData : notnull => Access<TAttribute, TData>(symbol).Single();

public TAttribute? AccessFirstOrDefault<TAttribute>(ISymbol symbol)
where TAttribute : Attribute => AccessFirstOrDefault<TAttribute, TAttribute>(symbol);

public TData? AccessFirstOrDefault<TAttribute, TData>(ISymbol symbol)
where TAttribute : Attribute
where TData : notnull => Access<TAttribute, TData>(symbol).FirstOrDefault();
Expand Down
5 changes: 5 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfiguration.cs
Expand Up @@ -110,4 +110,9 @@ public record MapperConfiguration
/// When <c>false</c>, accessible constructors are ordered in descending order by their parameter count.
/// </summary>
public bool? PreferParameterlessConstructors { get; init; }

/// <summary>
/// Whether to consider non-partial methods in a mapper as user implemented mapping methods.
/// </summary>
public bool? AutoUserMappings { get; set; }
}
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs
Expand Up @@ -59,6 +59,9 @@ public static MapperAttribute Merge(MapperConfiguration mapperConfiguration, Map
?? defaultMapperConfiguration.PreferParameterlessConstructors
?? mapper.PreferParameterlessConstructors;

mapper.AutoUserMappings =
mapperConfiguration.AutoUserMappings ?? defaultMapperConfiguration.AutoUserMappings ?? mapper.AutoUserMappings;

return mapper;
}
}
2 changes: 1 addition & 1 deletion src/Riok.Mapperly/Descriptors/MappingCollection.cs
Expand Up @@ -50,7 +50,7 @@ public class MappingCollection
return mapping;
}

public void EnqueueToBuildBody(IMapping mapping, MappingBuilderContext ctx) =>
public void EnqueueToBuildBody(ITypeMapping mapping, MappingBuilderContext ctx) =>
_mappingsToBuildBody.Enqueue((mapping, ctx), mapping.BodyBuildingPriority);

public void Add(ITypeMapping mapping, TypeMappingConfiguration config)
Expand Down
2 changes: 0 additions & 2 deletions src/Riok.Mapperly/Descriptors/Mappings/IMapping.cs
Expand Up @@ -10,6 +10,4 @@ public interface IMapping
ITypeSymbol SourceType { get; }

ITypeSymbol TargetType { get; }

MappingBodyBuildingPriority BodyBuildingPriority { get; }
}
2 changes: 2 additions & 0 deletions src/Riok.Mapperly/Descriptors/Mappings/ITypeMapping.cs
Expand Up @@ -15,4 +15,6 @@ public interface ITypeMapping : IMapping
/// Gets a value indicating whether this mapping produces any code or can be omitted completely (eg. direct assignments or delegate mappings).
/// </summary>
bool IsSynthetic { get; }

MappingBodyBuildingPriority BodyBuildingPriority { get; }
}
18 changes: 18 additions & 0 deletions src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Abstractions.ReferenceHandling;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.UserMappings;
Expand Down Expand Up @@ -91,13 +92,23 @@ private static bool IsMappingMethodCandidate(SimpleMappingBuilderContext ctx, IM
bool isStatic
)
{
var userMappingConfig = GetUserMappingConfig(ctx, method, out var hasAttribute);
var valid = !method.IsGenericMethod && (allowPartial || !method.IsPartialDefinition) && (!isStatic || method.IsStatic);

if (!valid || !BuildParameters(ctx, method, out var parameters))
{
if (hasAttribute)
{
var name = receiver == null ? method.Name : receiver + method.Name;
ctx.ReportDiagnostic(DiagnosticDescriptors.UnsupportedMappingMethodSignature, method, name);
}

return null;
}

if (userMappingConfig.Ignore)
return null;

if (method.ReturnsVoid)
{
return new UserImplementedExistingTargetMethodMapping(
Expand Down Expand Up @@ -374,4 +385,11 @@ bool isStatic
var targetCanBeNull = typeParameters?.TargetNullable ?? parameters.Target?.Type.IsNullable() ?? method.ReturnType.IsNullable();
return targetCanBeNull ? NullFallbackValue.Default : NullFallbackValue.ThrowArgumentNullException;
}

private static UserMappingAttribute GetUserMappingConfig(SimpleMappingBuilderContext ctx, IMethodSymbol method, out bool hasAttribute)
{
var userMappingAttr = ctx.AttributeAccessor.AccessFirstOrDefault<UserMappingAttribute>(method);
hasAttribute = userMappingAttr != null;
return userMappingAttr ?? new UserMappingAttribute { Ignore = !ctx.MapperConfiguration.AutoUserMappings };
}
}
81 changes: 81 additions & 0 deletions test/Riok.Mapperly.Tests/Mapping/UseMapperTest.cs
Expand Up @@ -311,4 +311,85 @@ public void DisabledNullableShouldWork()
"""
);
}

[Fact]
public void UseMapperWithDisabledAutoUserMappingsExplicitlyMarkedMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[UseMapper]
private readonly OtherMapper _otherMapper;
partial B Map(A source);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"record A(AExternal Value);",
"record B(BExternal Value);",
"record AExternal();",
"record BExternal();",
"class OtherMapper { [UserMapping] public BExternal ToBExternal(AExternal source) => new BExternal(); }"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(_otherMapper.ToBExternal(source.Value));
return target;
"""
);
}

[Fact]
public void UseMapperWithDisabledAutoUserMappingsExplicitlyMarkedButIgnored()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[UseMapper]
private readonly OtherMapper _otherMapper;
partial B Map(A source);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"record A(AExternal Value);",
"record B(BExternal Value);",
"record AExternal();",
"record BExternal();",
"class OtherMapper { [UserMapping(Ignore = true)] public BExternal ToBExternal(AExternal source) => new BExternal(); }"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(MapToBExternal(source.Value));
return target;
"""
);
}

[Fact]
public void UseMapperWithExplicitlyIgnoredMappingMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[UseMapper]
private readonly OtherMapper _otherMapper;
partial B Map(A source);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"record A(AExternal Value);",
"record B(BExternal Value);",
"record AExternal();",
"record BExternal();",
"class OtherMapper { [UserMapping(Ignore = true)] public BExternal ToBExternal(AExternal source) => new BExternal(); }"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(MapToBExternal(source.Value));
return target;
"""
);
}
}
112 changes: 112 additions & 0 deletions test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs
Expand Up @@ -429,4 +429,116 @@ public record C(string Name);
"""
);
}

[Fact]
public void DisabledAutoUserMappingsShouldIgnoreUserImplemented()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
public static int NotAMappingMethod(int s) => s;
public partial B Map(A s);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"public record A(int Value);",
"public record B(int Value);"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(s.Value);
return target;
"""
);
}

[Fact]
public void DisabledAutoUserMappingWithExplicitIncludedMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
public static int NotAMappingMethod(int s) => s;
[UserMapping]
public static int AMappingMethod(int s) => s;
public partial B Map(A s);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"public record A(int Value);",
"public record B(int Value);"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(AMappingMethod(s.Value));
return target;
"""
);
}

[Fact]
public void DisabledAutoUserMappingWithExplicitIncludedButIgnoredMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
public static int NotAMappingMethod(int s) => s;
[UserMapping(Ignore = true)]
public static int NotAMappingMethod2(int s) => s;
public partial B Map(A s);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"public record A(int Value);",
"public record B(int Value);"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(s.Value);
return target;
"""
);
}

[Fact]
public void EnabledAutoUserMappingWithIgnoredMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[UserMapping(Ignore = true)]
public static int NotAMappingMethod(int s) => s;
public partial B Map(A s);
""",
"public record A(int Value);",
"public record B(int Value);"
);
TestHelper
.GenerateMapper(source)
.Should()
.HaveMapMethodBody(
"""
var target = new global::B(s.Value);
return target;
"""
);
}

[Fact]
public Task UserMappingAttributeOnNonMappingMethod()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"""
[UserMapping]
public static void NotAMappingMethod2(int s) {}
public partial B Map(A s);
""",
TestSourceBuilderOptions.WithDisabledAutoUserMappings,
"public record A(int Value);",
"public record B(int Value);"
);
return TestHelper.VerifyGenerator(source);
}
}

0 comments on commit 4e4937c

Please sign in to comment.