Skip to content

Commit

Permalink
feat: allow usage of mappings in MapPropertyAttribute.Use which are a…
Browse files Browse the repository at this point in the history
…ttributed with UserMappingAttribute (#1151)
  • Loading branch information
latonz committed Mar 6, 2024
1 parent 5a50d81 commit 1f0b3c5
Show file tree
Hide file tree
Showing 50 changed files with 920 additions and 215 deletions.
9 changes: 7 additions & 2 deletions docs/docs/configuration/before-after-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ To run custom code before or after a mapping, the generated map method can be wr
[Mapper]
public partial class CarMapper
{
private partial CarDto CarToCarDto(Car car);

// highlight-start
// Default ensures Mapperly uses this mapping whenever a conversion
// from Car to CarDto is needed instead of the `CarToCarDto` method.
[UserMapping(Default = true)]
public CarDto MapCarToCarDto(Car car)
{
// custom before map code...
var dto = CarToCarDto(car);
// custom after map code...
return dto;
}

private partial CarDto CarToCarDto(Car car);
// highlight-end
}
```
65 changes: 44 additions & 21 deletions docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,27 +297,50 @@ the `Use` property of the `MapProperty` attribute can be used.
Set it to a unique name of a user-implemented mapping method.
The use of `nameof` is encouraged.
See also [user-implemented mapping methods](./user-implemented-methods.mdx).
The referenced mapping method needs to be discovered as a user-implemented
mapping method by Mapperly
(this happens automatically when `AutoUserMappings` is `true` (default) or manually with the `UserMapping` attribute).

```csharp
[Mapper]
public partial class CarMapper
{
// highlight-start
[MapProperty(nameof(Car.Price), nameof(CarDto.Price), Use = nameof(MapPrice))]
// highlight-end
public partial CarDto MapCar(Car source);
<Tabs>
<TabItem value="auto-user-mappings-enabled" label="enabled AutoUserMappings (default)" default>
```csharp
[Mapper]
public partial class CarMapper
{
// highlight-start
[MapProperty(nameof(Car.Price), nameof(CarDto.Price), Use = nameof(MapPrice))]
// highlight-end
public partial CarDto MapCar(Car source);

// highlight-start
// set Default = false to not use it for all decimal => string conversions
// if using AutoUserMappings = false, the UserMapping is not needed.
[UserMapping(Default = false)]
private string MapPrice(decimal price)
=> (price / 100).ToString("C");
// highlight-end
// generates
target.Price = MapPrice(source.Price);
}
```
</TabItem>
<TabItem value="auto-user-mappings-disabled" label="disabled AutoUserMappings">
```csharp
[Mapper(AutoUserMappings = false)]
public partial class CarMapper
{
// highlight-start
[MapProperty(nameof(Car.Price), nameof(CarDto.Price), Use = nameof(MapPrice))]
// highlight-end
public partial CarDto MapCar(Car source);

// highlight-start
// set Default = false to not use it for all decimal => string conversions
[UserMapping(Default = false)]
// highlight-end
private string MapPrice(decimal price)
=> (price / 100).ToString("C");
// highlight-start
private string MapPrice(decimal price)
=> (price / 100).ToString("C");
// highlight-end
// generates
target.Price = MapPrice(source.Price);
}
```
// generates
target.Price = MapPrice(source.Price);
}
```

</TabItem>
</Tabs>
10 changes: 7 additions & 3 deletions docs/docs/configuration/user-implemented-methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ and [default user-implemented mapping method](#default-user-implemented-mapping-
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
// highlight-start
Expand All @@ -50,6 +49,11 @@ public partial class CarMapper
}
```

The `AutoUserMappings` value also applies to the usage of external mappers:
With `AutoUserMappings` enabled all methods with a mapping method signature are discovered.
With `AutoUserMappings` disabled, only methods with a `UserMappingAttribute` and,
if the containing class has a `MapperAttribute`, partial methods are discovered.

## Ignoring a user-implemented mapping method

To ignore a user-implemented mapping method with enabled `AutoUserMappings`,
Expand Down Expand Up @@ -80,7 +84,7 @@ Whenever Mapperly will need a mapping for a given type-pair,
it will use the default user-implemented mapping.
A user-implemented mapping is considered the default mapping for a type-pair
if `Default = true` is set on the `UserMapping` attribute.
If no user-implemented mapping with `Default = true` exists,
If no user-implemented mapping with `Default = true` exists and `AutoUserMappings` is enabled,
the first user-implemented mapping which has an unspecified `Default` value is used.
For each type-pair only one default mapping can exist.

Expand Down Expand Up @@ -154,4 +158,4 @@ Whenever Mapperly needs a mapping from `BananaBox` to `BananaBoxDto` inside the
it will use the provided implementation by the `BananaMapper`.

Used mappers themselves can be Mapperly backed classes.
The `AutoUserMappings` value also applies to the usage of external mappers.
The [`AutoUserMappings`](#automatic-user-implemented-mapping-method-discovery) value also applies to the usage of external mappers.
11 changes: 10 additions & 1 deletion src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,16 @@ public class MapperAttribute : Attribute
public bool PreferParameterlessConstructors { get; set; } = true;

/// <summary>
/// Whether to consider non-partial methods in a mapper as user implemented mapping methods.
/// Whether to automatically discover user mapping methods based on their signature.
/// Partial methods are always considered mapping methods.
/// If <c>true</c>, all partial methods and methods with an implementation body and a mapping method signature are discovered as mapping methods.
/// If <c>false</c> only partial methods and methods with a <see cref="UserMappingAttribute"/> are discovered.
///
/// To discover mappings in external mappers (<seealso cref="UseMapperAttribute"/> and <seealso cref="UseStaticMapperAttribute"/>)
/// the same rules are applied:
/// If set to <c>true</c> all methods with a mapping method signature are automatically discovered.
/// If set to <c>false</c> methods with a <see cref="UserMappingAttribute"/> and if the containing class has a <see cref="MapperAttribute"/>
/// partial methods are discovered.
/// </summary>
public bool AutoUserMappings { get; set; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ public record UserMappingConfiguration
public bool? Default { get; set; }

/// <inheritdoc cref="UserMappingAttribute.Ignore"/>
public bool Ignore { get; set; }
public bool? Ignore { get; set; }
}
7 changes: 2 additions & 5 deletions src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ MapperConfiguration defaultMapperConfiguration
attributeAccessor,
_unsafeAccessorContext,
_diagnostics,
new MappingBuilder(_mappings),
new MappingBuilder(_mappings, mapperDeclaration),
new ExistingTargetMappingBuilder(_mappings),
mapperDeclaration.Syntax.GetLocation()
);
Expand Down Expand Up @@ -201,10 +201,7 @@ private void BuildReferenceHandlingParameters()
private void AddMappingsToDescriptor()
{
// add generated mappings to the mapper
foreach (var mapping in _mappings.MethodMappings)
{
_mapperDescriptor.AddTypeMapping(mapping);
}
_mapperDescriptor.AddMethodMappings(_mappings.MethodMappings);
}

private void AddAccessorsToDescriptor()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ public InlineExpressionMappingBuilderContext(MappingBuilderContext ctx, TypeMapp
IMethodSymbol? userSymbol,
Location? diagnosticLocation,
TypeMappingKey mappingKey,
bool clearDerivedTypes
bool ignoreDerivedTypes
)
: base(ctx, userSymbol, diagnosticLocation, mappingKey, clearDerivedTypes)
: base(ctx, userSymbol, diagnosticLocation, mappingKey, ignoreDerivedTypes)
{
_parentContext = ctx;
_inlineExpressionMappings = ctx._inlineExpressionMappings;
Expand All @@ -45,6 +45,22 @@ public override bool IsConversionEnabled(MappingConversionType conversionType)
conversionType is not MappingConversionType.EnumToString and not MappingConversionType.Dictionary
&& base.IsConversionEnabled(conversionType);

/// <summary>
/// <inheritdoc cref="MappingBuilderContext.FindNamedMapping"/>
/// Only returns <see cref="INewInstanceUserMapping"/>s.
/// </summary>
public override INewInstanceMapping? FindNamedMapping(string mappingName)
{
// Only user implemented mappings are taken into account.
// This works as long as the user implemented methods
// follow the expression tree limitations:
// https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/expression-trees/#limitations
if (base.FindNamedMapping(mappingName) is INewInstanceUserMapping mapping)
return mapping;

return null;
}

/// <summary>
/// Tries to find an existing mapping for the provided types + config.
/// The nullable annotation of reference types is ignored and always set to non-nullable.
Expand All @@ -62,45 +78,55 @@ public override bool IsConversionEnabled(MappingConversionType conversionType)
// follow the expression tree limitations:
// https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/expression-trees/#limitations
if (_parentContext.FindMapping(mappingKey) is UserImplementedMethodMapping userMapping)
{
_inlineExpressionMappings.AddMapping(userMapping, mappingKey.Configuration);
return userMapping;
}

return null;
}

/// <summary>
/// Always builds a new mapping with the user symbol of the first user defined mapping method for the provided types
/// or no user symbol if no user defined mapping is available unless if this <see cref="InlineExpressionMappingBuilderContext"/>
/// already built a mapping for the specified types, then this mapping is reused.
/// The nullable annotation of reference types is ignored and always set to non-nullable.
/// This ensures, the configuration of the user defined method is reused.
/// <seealso cref="MappingBuilderContext.FindOrBuildMapping"/>
/// Builds a new mapping but always uses the user symbol of the default defined mapping (if it is a user mapping)
/// or no user symbol if no user default mapping exists.
/// <seealso cref="MappingBuilderContext.BuildMapping"/>
/// </summary>
/// <param name="mappingKey">The mapping key.</param>
/// <param name="options">The options, <see cref="MappingBuildingOptions.MarkAsReusable"/> is ignored.</param>
/// <param name="diagnosticLocation">The updated to location where to report diagnostics if a new mapping is being built.</param>
/// <returns>The found or created mapping, or <c>null</c> if no mapping could be created.</returns>
public override INewInstanceMapping? FindOrBuildMapping(
/// <returns>The created mapping, or <c>null</c> if no mapping could be created.</returns>
public override INewInstanceMapping? BuildMapping(
TypeMappingKey mappingKey,
MappingBuildingOptions options = MappingBuildingOptions.Default,
Location? diagnosticLocation = null
)
{
var mapping = FindMapping(mappingKey);
if (mapping != null)
return mapping;

var userSymbol = options.HasFlag(MappingBuildingOptions.KeepUserSymbol) ? UserSymbol : null;

// inline expression mappings don't reuse the user-defined mappings directly
// but to apply the same configurations the default mapping user symbol is used
// if there is no other user symbol.
// this makes sure the configuration of the default mapping user symbol is used
// for inline expression mappings.
// This is not needed for regular mappings as these user defined method mappings
// are directly built (with KeepUserSymbol) and called by the other mappings.
userSymbol ??= (MappingBuilder.Find(mappingKey) as IUserMapping)?.Method;
options &= ~MappingBuildingOptions.KeepUserSymbol;
return BuildMapping(userSymbol, mappingKey, options, diagnosticLocation);
}

// unset MarkAsReusable and KeepUserSymbol as they have special handling for inline mappings
options &= ~(MappingBuildingOptions.MarkAsReusable | MappingBuildingOptions.KeepUserSymbol);

mapping = BuildMapping(userSymbol, mappingKey, options, diagnosticLocation);
if (mapping != null)
protected override INewInstanceMapping? BuildMapping(
IMethodSymbol? userSymbol,
TypeMappingKey mappingKey,
MappingBuildingOptions options,
Location? diagnosticLocation
)
{
// unset mark as reusable as an inline expression mapping
// should never be reused by the default mapping builder context,
// only by other inline mapping builder contexts.
var reusable = (options & MappingBuildingOptions.MarkAsReusable) != MappingBuildingOptions.MarkAsReusable;
options &= ~MappingBuildingOptions.MarkAsReusable;

var mapping = base.BuildMapping(userSymbol, mappingKey, options, diagnosticLocation);
if (reusable && mapping != null)
{
_inlineExpressionMappings.AddMapping(mapping, mappingKey.Configuration);
}
Expand Down Expand Up @@ -145,7 +171,7 @@ public override bool IsConversionEnabled(MappingConversionType conversionType)
userSymbol,
diagnosticLocation,
mappingKey,
options.HasFlag(MappingBuildingOptions.ClearDerivedTypes)
options.HasFlag(MappingBuildingOptions.IgnoreDerivedTypes)
);
}
}
6 changes: 3 additions & 3 deletions src/Riok.Mapperly/Descriptors/MapperDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ public MapperDescriptor(MapperDeclaration declaration, UniqueNameBuilder nameBui

public UniqueNameBuilder NameBuilder { get; }

public IReadOnlyCollection<MethodMapping> MethodTypeMappings => _methodMappings;
public IReadOnlyCollection<MethodMapping> MethodMappings => _methodMappings;

public IReadOnlyCollection<IUnsafeAccessor> UnsafeAccessors => _unsafeAccessors;

public void AddTypeMapping(MethodMapping mapping) => _methodMappings.Add(mapping);
public void AddMethodMappings(IReadOnlyCollection<MethodMapping> mappings) => _methodMappings.AddRange(mappings);

public void AddUnsafeAccessors(IEnumerable<IUnsafeAccessor> accessors) => _unsafeAccessors.AddRange(accessors);
public void AddUnsafeAccessors(IReadOnlyCollection<IUnsafeAccessor> accessors) => _unsafeAccessors.AddRange(accessors);

private string BuildName(INamedTypeSymbol symbol)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ public static void BuildMappingBody(MappingBuilderContext ctx, NewInstanceObject
{
var mappingCtx = new NewInstanceBuilderContext<NewInstanceObjectMemberMapping>(ctx, mapping);
BuildConstructorMapping(mappingCtx);
BuildInitOnlyMemberMappings(mappingCtx, true);
BuildInitMemberMappings(mappingCtx, true);
mappingCtx.AddDiagnostics();
}

public static void BuildMappingBody(MappingBuilderContext ctx, NewInstanceObjectMemberMethodMapping mapping)
{
var mappingCtx = new NewInstanceContainerBuilderContext<NewInstanceObjectMemberMethodMapping>(ctx, mapping);
BuildConstructorMapping(mappingCtx);
BuildInitOnlyMemberMappings(mappingCtx);
BuildInitMemberMappings(mappingCtx);
ObjectMemberMappingBodyBuilder.BuildMappingBody(mappingCtx);
}

private static void BuildInitOnlyMemberMappings(INewInstanceBuilderContext<IMapping> ctx, bool includeAllMembers = false)
private static void BuildInitMemberMappings(INewInstanceBuilderContext<IMapping> ctx, bool includeAllMembers = false)
{
var ignoreCase = ctx.BuilderContext.Configuration.Mapper.PropertyNameMappingStrategy == PropertyNameMappingStrategy.CaseInsensitive;

Expand Down
18 changes: 9 additions & 9 deletions src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ public class MappingBuilderContext : SimpleMappingBuilderContext
IMethodSymbol? userSymbol,
Location? diagnosticLocation,
TypeMappingKey mappingKey,
bool clearDerivedTypes
bool ignoreDerivedTypes
)
: this(ctx, ctx.ObjectFactories, ctx._formatProviders, userSymbol, mappingKey, diagnosticLocation)
{
if (clearDerivedTypes)
if (ignoreDerivedTypes)
{
Configuration = Configuration with { DerivedTypes = Array.Empty<DerivedTypeMappingConfiguration>() };
}
Expand Down Expand Up @@ -78,9 +78,9 @@ bool clearDerivedTypes
/// </summary>
/// <param name="mappingName">The name of the mapping.</param>
/// <returns>The found mapping, or <c>null</c> if none is found.</returns>
public INewInstanceMapping? FindNamedMapping(string mappingName)
public virtual INewInstanceMapping? FindNamedMapping(string mappingName)
{
var mapping = MappingBuilder.FindNamed(mappingName, out var ambiguousName);
var mapping = MappingBuilder.FindOrResolveNamed(this, mappingName, out var ambiguousName);
if (ambiguousName)
{
ReportDiagnostic(DiagnosticDescriptors.ReferencedMappingAmbiguous, mappingName);
Expand Down Expand Up @@ -143,13 +143,13 @@ bool clearDerivedTypes
/// <param name="options">The mapping building options.</param>
/// <param name="diagnosticLocation">The updated to location where to report diagnostics if a new mapping is being built.</param>
/// <returns>The found or created mapping, or <c>null</c> if no mapping could be created.</returns>
public virtual INewInstanceMapping? FindOrBuildMapping(
public INewInstanceMapping? FindOrBuildMapping(
TypeMappingKey mappingKey,
MappingBuildingOptions options = MappingBuildingOptions.Default,
Location? diagnosticLocation = null
)
{
return MappingBuilder.Find(mappingKey) ?? BuildMapping(mappingKey, options, diagnosticLocation);
return FindMapping(mappingKey) ?? BuildMapping(mappingKey, options, diagnosticLocation);
}

/// <summary>
Expand Down Expand Up @@ -179,7 +179,7 @@ bool clearDerivedTypes
/// <param name="options">The options.</param>
/// <param name="diagnosticLocation">The updated to location where to report diagnostics if a new mapping is being built.</param>
/// <returns>The created mapping, or <c>null</c> if no mapping could be created.</returns>
public INewInstanceMapping? BuildMapping(
public virtual INewInstanceMapping? BuildMapping(
TypeMappingKey mappingKey,
MappingBuildingOptions options = MappingBuildingOptions.Default,
Location? diagnosticLocation = null
Expand Down Expand Up @@ -326,10 +326,10 @@ protected virtual NullFallbackValue GetNullFallbackValue(ITypeSymbol targetType,
Location? diagnosticLocation = null
)
{
return new(this, userSymbol, diagnosticLocation, mappingKey, options.HasFlag(MappingBuildingOptions.ClearDerivedTypes));
return new(this, userSymbol, diagnosticLocation, mappingKey, options.HasFlag(MappingBuildingOptions.IgnoreDerivedTypes));
}

protected INewInstanceMapping? BuildMapping(
protected virtual INewInstanceMapping? BuildMapping(
IMethodSymbol? userSymbol,
TypeMappingKey mappingKey,
MappingBuildingOptions options,
Expand Down
Loading

0 comments on commit 1f0b3c5

Please sign in to comment.