Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add default mapper attribute for assemblies #657

Merged
merged 1 commit into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
latonz marked this conversation as resolved.
Show resolved Hide resolved
{
/// <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 @@
_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<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}");

Check warning on line 78 in src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs#L78

Added line #L78 was not covered by tests

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 @@
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
{
latonz marked this conversation as resolved.
Show resolved Hide resolved
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
Loading