Skip to content

Commit

Permalink
feat: add mapper defaults attribute for assemblies (#657)
Browse files Browse the repository at this point in the history
  • Loading branch information
ni507 authored Aug 30, 2023
1 parent ed84a26 commit 765aa0b
Show file tree
Hide file tree
Showing 15 changed files with 452 additions and 122 deletions.
8 changes: 8 additions & 0 deletions docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,11 @@ 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](./enum.mdx).

## Default Mapper configuration

The `MapperDefaultsAttribute` allows to set default configurations applied to all mappers on the assembly level.

```csharp
[assembly: MapperDefaults(EnumMappingIgnoreCase = true)]
```
2 changes: 1 addition & 1 deletion src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Riok.Mapperly.Abstractions;
/// Marks a partial class as a mapper.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class MapperAttribute : Attribute
public class MapperAttribute : Attribute
{
/// <summary>
/// Strategy on how to match mapping property names.
Expand Down
7 changes: 7 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperDefaultsAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Used to set mapper default values in the assembly.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public sealed class MapperDefaultsAttribute : MapperAttribute { }
2 changes: 2 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Riok.Mapperly.Abstractions.MapperAttribute.ThrowOnPropertyMappingNullMismatch.ge
Riok.Mapperly.Abstractions.MapperAttribute.ThrowOnPropertyMappingNullMismatch.set -> void
Riok.Mapperly.Abstractions.MapperAttribute.UseDeepCloning.get -> bool
Riok.Mapperly.Abstractions.MapperAttribute.UseDeepCloning.set -> void
Riok.Mapperly.Abstractions.MapperDefaultsAttribute
Riok.Mapperly.Abstractions.MapperDefaultsAttribute.MapperDefaultsAttribute() -> void
Riok.Mapperly.Abstractions.MapperConstructorAttribute
Riok.Mapperly.Abstractions.MapperConstructorAttribute.MapperConstructorAttribute() -> void
Riok.Mapperly.Abstractions.MapperIgnoreObsoleteMembersAttribute
Expand Down
3 changes: 2 additions & 1 deletion src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,5 @@ RMG047 | Mapper | Error | Cannot map to member path due to modifying a tem

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RMG048 | Mapper | Error | Used mapper members cannot be nullable
RMG048 | Mapper | Error | Used mapper members cannot be nullable

67 changes: 40 additions & 27 deletions src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ public AttributeDataAccessor(SymbolAccessor symbolAccessor)
_symbolAccessor = symbolAccessor;
}

public T AccessSingle<T>(ISymbol symbol)
where T : Attribute => Access<T, T>(symbol).Single();
public TData AccessSingle<TAttribute, TData>(ISymbol symbol)
where TAttribute : Attribute
where TData : notnull => Access<TAttribute, TData>(symbol).Single();

public TData? AccessFirstOrDefault<TAttribute, TData>(ISymbol symbol)
where TAttribute : Attribute
Expand All @@ -47,38 +48,44 @@ public IEnumerable<TAttribute> Access<TAttribute>(ISymbol symbol)
public IEnumerable<TData> Access<TAttribute, TData>(ISymbol symbol)
where TAttribute : Attribute
where TData : notnull
{
var attrDatas = _symbolAccessor.GetAttributes<TAttribute>(symbol);
foreach (var attrData in attrDatas)
{
yield return Access<TAttribute, TData>(attrData);
}
}

public static TData Access<TAttribute, TData>(AttributeData attrData)
where TAttribute : Attribute
where TData : notnull
{
var attrType = typeof(TAttribute);
var dataType = typeof(TData);

var attrDatas = _symbolAccessor.GetAttributes<TAttribute>(symbol);
var syntax = (AttributeSyntax?)attrData.ApplicationSyntaxReference?.GetSyntax();
var syntaxArguments =
(IReadOnlyList<AttributeArgumentSyntax>?)syntax?.ArgumentList?.Arguments
?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length];
var typeArguments = (IReadOnlyCollection<ITypeSymbol>?)attrData.AttributeClass?.TypeArguments ?? Array.Empty<ITypeSymbol>();
var attr = Create<TData>(typeArguments, attrData.ConstructorArguments, syntaxArguments);

foreach (var attrData in attrDatas)
var syntaxIndex = attrData.ConstructorArguments.Length;
var propertiesByName = dataType.GetProperties().GroupBy(x => x.Name).ToDictionary(x => x.Key, x => x.First());
foreach (var namedArgument in attrData.NamedArguments)
{
var syntax = (AttributeSyntax?)attrData.ApplicationSyntaxReference?.GetSyntax();
var syntaxArguments =
(IReadOnlyList<AttributeArgumentSyntax>?)syntax?.ArgumentList?.Arguments
?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length];
var typeArguments = (IReadOnlyCollection<ITypeSymbol>?)attrData.AttributeClass?.TypeArguments ?? Array.Empty<ITypeSymbol>();
var attr = Create<TData>(typeArguments, attrData.ConstructorArguments, syntaxArguments);

var syntaxIndex = attrData.ConstructorArguments.Length;
var propertiesByName = dataType.GetProperties().GroupBy(x => x.Name).ToDictionary(x => x.Key, x => x.First());
foreach (var namedArgument in attrData.NamedArguments)
{
if (!propertiesByName.TryGetValue(namedArgument.Key, out var prop))
throw new InvalidOperationException($"Could not get property {namedArgument.Key} of attribute {attrType.FullName}");

var value = BuildArgumentValue(namedArgument.Value, prop.PropertyType, syntaxArguments[syntaxIndex]);
prop.SetValue(attr, value);
syntaxIndex++;
}
if (!propertiesByName.TryGetValue(namedArgument.Key, out var prop))
throw new InvalidOperationException($"Could not get property {namedArgument.Key} of attribute {attrType.FullName}");

yield return attr;
var value = BuildArgumentValue(namedArgument.Value, prop.PropertyType, syntaxArguments[syntaxIndex]);
prop.SetValue(attr, value);
syntaxIndex++;
}

return attr;
}

private TData Create<TData>(
private static TData Create<TData>(
IReadOnlyCollection<ITypeSymbol> typeArguments,
IReadOnlyCollection<TypedConstant> constructorArguments,
IReadOnlyList<AttributeArgumentSyntax> argumentSyntax
Expand Down Expand Up @@ -180,8 +187,14 @@ is InvocationExpressionSyntax
return null;

var enumRoslynType = arg.Type ?? throw new InvalidOperationException("Type is null");
return targetType == typeof(IFieldSymbol)
? enumRoslynType.GetFields().First(f => Equals(f.ConstantValue, arg.Value))
: Enum.ToObject(targetType, arg.Value);
if (targetType == typeof(IFieldSymbol))
return enumRoslynType.GetFields().First(f => Equals(f.ConstantValue, arg.Value));

if (targetType.IsConstructedGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>))
{
targetType = Nullable.GetUnderlyingType(targetType)!;
}

return Enum.ToObject(targetType, arg.Value);
}
}
160 changes: 76 additions & 84 deletions src/Riok.Mapperly/Configuration/MapperConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,100 +1,92 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Configuration;

/// <summary>
/// Contains all the properties of <see cref="MapperAttribute"/> and <see cref="MapperDefaultsAttribute"/> but all of them are nullable.
/// This is needed to evaluate which properties are set in the <see cref="MapperAttribute"/> and <see cref="MapperDefaultsAttribute"/>.
/// <remarks>
/// Newly added properties must also be added to <see cref="Riok.Mapperly.Helpers.MapperConfigurationBuilder"/>.
/// </remarks>
/// </summary>
public class MapperConfiguration
{
private readonly MappingConfiguration _defaultConfiguration;
private readonly AttributeDataAccessor _dataAccessor;
/// <summary>
/// Strategy on how to match mapping property names.
/// </summary>
public PropertyNameMappingStrategy? PropertyNameMappingStrategy { get; set; }

public MapperConfiguration(AttributeDataAccessor dataAccessor, ISymbol mapperSymbol)
{
_dataAccessor = dataAccessor;
Mapper = _dataAccessor.AccessSingle<MapperAttribute>(mapperSymbol);
_defaultConfiguration = new MappingConfiguration(
new EnumMappingConfiguration(
Mapper.EnumMappingStrategy,
Mapper.EnumMappingIgnoreCase,
null,
Array.Empty<IFieldSymbol>(),
Array.Empty<IFieldSymbol>(),
Array.Empty<EnumValueMappingConfiguration>()
),
new PropertiesMappingConfiguration(
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<PropertyMappingConfiguration>(),
Mapper.IgnoreObsoleteMembersStrategy
),
Array.Empty<DerivedTypeMappingConfiguration>()
);
}
/// <summary>
/// The default enum mapping strategy.
/// Can be overwritten on specific enums via mapping method configurations.
/// </summary>
public EnumMappingStrategy? EnumMappingStrategy { get; set; }

public MapperAttribute Mapper { get; }
/// <summary>
/// Whether the case should be ignored for enum mappings.
/// </summary>
public bool? EnumMappingIgnoreCase { get; set; }

public MappingConfiguration BuildFor(MappingConfigurationReference reference)
{
if (reference.Method == null)
return _defaultConfiguration;
/// <summary>
/// Specifies the behaviour in the case when the mapper tries to return <c>null</c> in a mapping method with a non-nullable return type.
/// If set to <c>true</c> an <see cref="ArgumentNullException"/> is thrown.
/// If set to <c>false</c> the mapper tries to return a default value.
/// For a <see cref="string"/> this is <see cref="string.Empty"/>,
/// for value types <c>default</c>
/// and for reference types <c>new()</c> if a parameterless constructor exists or else an <see cref="ArgumentNullException"/> is thrown.
/// </summary>
public bool? ThrowOnMappingNullMismatch { get; set; }

var enumConfig = BuildEnumConfig(reference);
var propertiesConfig = BuildPropertiesConfig(reference.Method);
var derivedTypesConfig = BuildDerivedTypeConfigs(reference.Method);
return new MappingConfiguration(enumConfig, propertiesConfig, derivedTypesConfig);
}
/// <summary>
/// Specifies the behaviour in the case when the mapper tries to set a non-nullable property to a <c>null</c> value.
/// If set to <c>true</c> an <see cref="ArgumentNullException"/> is thrown.
/// If set to <c>false</c> the property assignment is ignored.
/// This is ignored for required init properties and <see cref="IQueryable{T}"/> projection mappings.
/// </summary>
public bool? ThrowOnPropertyMappingNullMismatch { get; set; }

private IReadOnlyCollection<DerivedTypeMappingConfiguration> BuildDerivedTypeConfigs(IMethodSymbol method)
{
return _dataAccessor
.Access<MapDerivedTypeAttribute, DerivedTypeMappingConfiguration>(method)
.Concat(_dataAccessor.Access<MapDerivedTypeAttribute<object, object>, DerivedTypeMappingConfiguration>(method))
.ToList();
}
/// <summary>
/// Specifies whether <c>null</c> values are assigned to the target.
/// If <c>true</c> (default), the source is <c>null</c>, and the target does allow <c>null</c> values,
/// <c>null</c> is assigned.
/// If <c>false</c>, <c>null</c> values are never assigned to the target property.
/// This is ignored for required init properties and <see cref="IQueryable{T}"/> projection mappings.
/// </summary>
public bool? AllowNullPropertyAssignment { get; set; }

private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol method)
{
var ignoredSourceProperties = _dataAccessor
.Access<MapperIgnoreSourceAttribute>(method)
.Select(x => x.Source)
.WhereNotNull()
.ToList();
var ignoredTargetProperties = _dataAccessor
.Access<MapperIgnoreTargetAttribute>(method)
.Select(x => x.Target)
.WhereNotNull()
.ToList();
var explicitMappings = _dataAccessor.Access<MapPropertyAttribute, PropertyMappingConfiguration>(method).ToList();
var ignoreObsolete = _dataAccessor.Access<MapperIgnoreObsoleteMembersAttribute>(method).FirstOrDefault() is not { } methodIgnore
? _defaultConfiguration.Properties.IgnoreObsoleteMembersStrategy
: methodIgnore.IgnoreObsoleteStrategy;
/// <summary>
/// Whether to always deep copy objects.
/// Eg. when the type <c>Person[]</c> should be mapped to the same type <c>Person[]</c>,
/// when <c>false</c>, the same array is reused.
/// when <c>true</c>, the array and each person is cloned.
/// </summary>
public bool? UseDeepCloning { get; set; }

return new PropertiesMappingConfiguration(ignoredSourceProperties, ignoredTargetProperties, explicitMappings, ignoreObsolete);
}
/// <summary>
/// Enabled conversions which Mapperly automatically implements.
/// By default all supported type conversions are enabled.
/// <example>
/// Eg. to disable all automatically implemented conversions:<br />
/// <c>EnabledConversions = MappingConversionType.None</c>
/// </example>
/// <example>
/// Eg. to disable <c>ToString()</c> method calls:<br />
/// <c>EnabledConversions = MappingConversionType.All &amp; ~MappingConversionType.ToStringMethod</c>
/// </example>
/// </summary>
public MappingConversionType? EnabledConversions { get; set; }

private EnumMappingConfiguration BuildEnumConfig(MappingConfigurationReference configRef)
{
if (configRef.Method == null || !configRef.Source.IsEnum() && !configRef.Target.IsEnum())
return _defaultConfiguration.Enum;
/// <summary>
/// Enables the reference handling feature.
/// Disabled by default for performance reasons.
/// When enabled, an <see cref="IReferenceHandler"/> instance is passed through the mapping methods
/// to keep track of and reuse existing target object instances.
/// </summary>
public bool? UseReferenceHandling { get; set; }

var configData = _dataAccessor.AccessFirstOrDefault<MapEnumAttribute, EnumConfiguration>(configRef.Method);
var explicitMappings = _dataAccessor.Access<MapEnumValueAttribute, EnumValueMappingConfiguration>(configRef.Method).ToList();
var ignoredSources = _dataAccessor
.Access<MapperIgnoreSourceValueAttribute, MapperIgnoreEnumValueConfiguration>(configRef.Method)
.Select(x => x.Value)
.ToList();
var ignoredTargets = _dataAccessor
.Access<MapperIgnoreTargetValueAttribute, MapperIgnoreEnumValueConfiguration>(configRef.Method)
.Select(x => x.Value)
.ToList();
return new EnumMappingConfiguration(
configData?.Strategy ?? _defaultConfiguration.Enum.Strategy,
configData?.IgnoreCase ?? _defaultConfiguration.Enum.IgnoreCase,
configData?.FallbackValue,
ignoredSources,
ignoredTargets,
explicitMappings
);
}
/// <summary>
/// The ignore obsolete attribute strategy. Determines how <see cref="ObsoleteAttribute"/> marked members are mapped.
/// Defaults to <see cref="IgnoreObsoleteMembersStrategy.None"/>.
/// </summary>
public IgnoreObsoleteMembersStrategy? IgnoreObsoleteMembersStrategy { get; set; }
}
Loading

0 comments on commit 765aa0b

Please sign in to comment.