From 765aa0b99e77c3c3ccf0127fd77f9b16b183a5e3 Mon Sep 17 00:00:00 2001 From: Silvan Date: Wed, 30 Aug 2023 09:53:40 +0200 Subject: [PATCH] feat: add mapper defaults attribute for assemblies (#657) --- docs/docs/configuration/mapper.mdx | 8 + .../MapperAttribute.cs | 2 +- .../MapperDefaultsAttribute.cs | 7 + .../PublicAPI.Shipped.txt | 2 + src/Riok.Mapperly/AnalyzerReleases.Shipped.md | 3 +- .../Configuration/AttributeDataAccessor.cs | 67 +++++--- .../Configuration/MapperConfiguration.cs | 160 +++++++++--------- .../MapperConfigurationReader.cs | 106 ++++++++++++ .../Descriptors/DescriptorBuilder.cs | 5 +- .../SimpleMappingBuilderContext.cs | 12 +- .../Helpers/MapperConfigurationBuilder.cs | 52 ++++++ src/Riok.Mapperly/MapperGenerator.cs | 24 ++- .../Helpers/MapperConfigurationBuilderTest.cs | 90 ++++++++++ .../Riok.Mapperly.Tests/Mapping/MapperTest.cs | 22 +++ ...AttributeShouldWork#MyMapper.g.verified.cs | 14 ++ 15 files changed, 452 insertions(+), 122 deletions(-) create mode 100644 src/Riok.Mapperly.Abstractions/MapperDefaultsAttribute.cs create mode 100644 src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs create mode 100644 src/Riok.Mapperly/Helpers/MapperConfigurationBuilder.cs create mode 100644 test/Riok.Mapperly.Tests/Helpers/MapperConfigurationBuilderTest.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/MapperTest.AssemblyAttributeShouldWork#MyMapper.g.verified.cs diff --git a/docs/docs/configuration/mapper.mdx b/docs/docs/configuration/mapper.mdx index fc947dd218..c0be1a2719 100644 --- a/docs/docs/configuration/mapper.mdx +++ b/docs/docs/configuration/mapper.mdx @@ -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)] +``` diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index 2f73dcd219..a8c7de5750 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -6,7 +6,7 @@ namespace Riok.Mapperly.Abstractions; /// Marks a partial class as a mapper. /// [AttributeUsage(AttributeTargets.Class)] -public sealed class MapperAttribute : Attribute +public class MapperAttribute : Attribute { /// /// Strategy on how to match mapping property names. diff --git a/src/Riok.Mapperly.Abstractions/MapperDefaultsAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperDefaultsAttribute.cs new file mode 100644 index 0000000000..3250ff14f5 --- /dev/null +++ b/src/Riok.Mapperly.Abstractions/MapperDefaultsAttribute.cs @@ -0,0 +1,7 @@ +namespace Riok.Mapperly.Abstractions; + +/// +/// Used to set mapper default values in the assembly. +/// +[AttributeUsage(AttributeTargets.Assembly)] +public sealed class MapperDefaultsAttribute : MapperAttribute { } diff --git a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt index 94add9f456..71b8efc981 100644 --- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt +++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt @@ -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 diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index bfafc2ad34..81ba862e17 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -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 + diff --git a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs index 6d7b9145c6..de7ec73a25 100644 --- a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs +++ b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs @@ -20,8 +20,9 @@ public AttributeDataAccessor(SymbolAccessor symbolAccessor) _symbolAccessor = symbolAccessor; } - public T AccessSingle(ISymbol symbol) - where T : Attribute => Access(symbol).Single(); + public TData AccessSingle(ISymbol symbol) + where TAttribute : Attribute + where TData : notnull => Access(symbol).Single(); public TData? AccessFirstOrDefault(ISymbol symbol) where TAttribute : Attribute @@ -47,38 +48,44 @@ public IEnumerable Access(ISymbol symbol) public IEnumerable Access(ISymbol symbol) where TAttribute : Attribute where TData : notnull + { + var attrDatas = _symbolAccessor.GetAttributes(symbol); + foreach (var attrData in attrDatas) + { + yield return Access(attrData); + } + } + + public static TData Access(AttributeData attrData) + where TAttribute : Attribute + where TData : notnull { var attrType = typeof(TAttribute); var dataType = typeof(TData); - var attrDatas = _symbolAccessor.GetAttributes(symbol); + var syntax = (AttributeSyntax?)attrData.ApplicationSyntaxReference?.GetSyntax(); + var syntaxArguments = + (IReadOnlyList?)syntax?.ArgumentList?.Arguments + ?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length]; + var typeArguments = (IReadOnlyCollection?)attrData.AttributeClass?.TypeArguments ?? Array.Empty(); + var attr = Create(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?)syntax?.ArgumentList?.Arguments - ?? new AttributeArgumentSyntax[attrData.ConstructorArguments.Length + attrData.NamedArguments.Length]; - var typeArguments = (IReadOnlyCollection?)attrData.AttributeClass?.TypeArguments ?? Array.Empty(); - var attr = Create(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( + private static TData Create( IReadOnlyCollection typeArguments, IReadOnlyCollection constructorArguments, IReadOnlyList argumentSyntax @@ -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); } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs index 6f30b239c9..9d2555584f 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs @@ -1,100 +1,92 @@ -using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; -using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Configuration; +/// +/// Contains all the properties of and but all of them are nullable. +/// This is needed to evaluate which properties are set in the and . +/// +/// Newly added properties must also be added to . +/// +/// public class MapperConfiguration { - private readonly MappingConfiguration _defaultConfiguration; - private readonly AttributeDataAccessor _dataAccessor; + /// + /// Strategy on how to match mapping property names. + /// + public PropertyNameMappingStrategy? PropertyNameMappingStrategy { get; set; } - public MapperConfiguration(AttributeDataAccessor dataAccessor, ISymbol mapperSymbol) - { - _dataAccessor = dataAccessor; - Mapper = _dataAccessor.AccessSingle(mapperSymbol); - _defaultConfiguration = new MappingConfiguration( - new EnumMappingConfiguration( - Mapper.EnumMappingStrategy, - Mapper.EnumMappingIgnoreCase, - null, - Array.Empty(), - Array.Empty(), - Array.Empty() - ), - new PropertiesMappingConfiguration( - Array.Empty(), - Array.Empty(), - Array.Empty(), - Mapper.IgnoreObsoleteMembersStrategy - ), - Array.Empty() - ); - } + /// + /// The default enum mapping strategy. + /// Can be overwritten on specific enums via mapping method configurations. + /// + public EnumMappingStrategy? EnumMappingStrategy { get; set; } - public MapperAttribute Mapper { get; } + /// + /// Whether the case should be ignored for enum mappings. + /// + public bool? EnumMappingIgnoreCase { get; set; } - public MappingConfiguration BuildFor(MappingConfigurationReference reference) - { - if (reference.Method == null) - return _defaultConfiguration; + /// + /// Specifies the behaviour in the case when the mapper tries to return null in a mapping method with a non-nullable return type. + /// If set to true an is thrown. + /// If set to false the mapper tries to return a default value. + /// For a this is , + /// for value types default + /// and for reference types new() if a parameterless constructor exists or else an is thrown. + /// + 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); - } + /// + /// Specifies the behaviour in the case when the mapper tries to set a non-nullable property to a null value. + /// If set to true an is thrown. + /// If set to false the property assignment is ignored. + /// This is ignored for required init properties and projection mappings. + /// + public bool? ThrowOnPropertyMappingNullMismatch { get; set; } - private IReadOnlyCollection BuildDerivedTypeConfigs(IMethodSymbol method) - { - return _dataAccessor - .Access(method) - .Concat(_dataAccessor.Access, DerivedTypeMappingConfiguration>(method)) - .ToList(); - } + /// + /// Specifies whether null values are assigned to the target. + /// If true (default), the source is null, and the target does allow null values, + /// null is assigned. + /// If false, null values are never assigned to the target property. + /// This is ignored for required init properties and projection mappings. + /// + public bool? AllowNullPropertyAssignment { get; set; } - private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol method) - { - var ignoredSourceProperties = _dataAccessor - .Access(method) - .Select(x => x.Source) - .WhereNotNull() - .ToList(); - var ignoredTargetProperties = _dataAccessor - .Access(method) - .Select(x => x.Target) - .WhereNotNull() - .ToList(); - var explicitMappings = _dataAccessor.Access(method).ToList(); - var ignoreObsolete = _dataAccessor.Access(method).FirstOrDefault() is not { } methodIgnore - ? _defaultConfiguration.Properties.IgnoreObsoleteMembersStrategy - : methodIgnore.IgnoreObsoleteStrategy; + /// + /// Whether to always deep copy objects. + /// Eg. when the type Person[] should be mapped to the same type Person[], + /// when false, the same array is reused. + /// when true, the array and each person is cloned. + /// + public bool? UseDeepCloning { get; set; } - return new PropertiesMappingConfiguration(ignoredSourceProperties, ignoredTargetProperties, explicitMappings, ignoreObsolete); - } + /// + /// Enabled conversions which Mapperly automatically implements. + /// By default all supported type conversions are enabled. + /// + /// Eg. to disable all automatically implemented conversions:
+ /// EnabledConversions = MappingConversionType.None + ///
+ /// + /// Eg. to disable ToString() method calls:
+ /// EnabledConversions = MappingConversionType.All & ~MappingConversionType.ToStringMethod + ///
+ ///
+ public MappingConversionType? EnabledConversions { get; set; } - private EnumMappingConfiguration BuildEnumConfig(MappingConfigurationReference configRef) - { - if (configRef.Method == null || !configRef.Source.IsEnum() && !configRef.Target.IsEnum()) - return _defaultConfiguration.Enum; + /// + /// Enables the reference handling feature. + /// Disabled by default for performance reasons. + /// When enabled, an instance is passed through the mapping methods + /// to keep track of and reuse existing target object instances. + /// + public bool? UseReferenceHandling { get; set; } - var configData = _dataAccessor.AccessFirstOrDefault(configRef.Method); - var explicitMappings = _dataAccessor.Access(configRef.Method).ToList(); - var ignoredSources = _dataAccessor - .Access(configRef.Method) - .Select(x => x.Value) - .ToList(); - var ignoredTargets = _dataAccessor - .Access(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 - ); - } + /// + /// The ignore obsolete attribute strategy. Determines how marked members are mapped. + /// Defaults to . + /// + public IgnoreObsoleteMembersStrategy? IgnoreObsoleteMembersStrategy { get; set; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs new file mode 100644 index 0000000000..21227db2e6 --- /dev/null +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs @@ -0,0 +1,106 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Configuration; + +public class MapperConfigurationReader +{ + private readonly MappingConfiguration _defaultConfiguration; + private readonly AttributeDataAccessor _dataAccessor; + + public MapperConfigurationReader( + AttributeDataAccessor dataAccessor, + ISymbol mapperSymbol, + MapperConfiguration? defaultMapperConfiguration + ) + { + _dataAccessor = dataAccessor; + var mapperConfiguration = _dataAccessor.AccessSingle(mapperSymbol); + Mapper = MapperConfigurationBuilder.Merge(mapperConfiguration, defaultMapperConfiguration); + + _defaultConfiguration = new MappingConfiguration( + new EnumMappingConfiguration( + Mapper.EnumMappingStrategy, + Mapper.EnumMappingIgnoreCase, + null, + Array.Empty(), + Array.Empty(), + Array.Empty() + ), + new PropertiesMappingConfiguration( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Mapper.IgnoreObsoleteMembersStrategy + ), + Array.Empty() + ); + } + + public MapperAttribute Mapper { get; } + + public MappingConfiguration BuildFor(MappingConfigurationReference reference) + { + if (reference.Method == null) + return _defaultConfiguration; + + var enumConfig = BuildEnumConfig(reference); + var propertiesConfig = BuildPropertiesConfig(reference.Method); + var derivedTypesConfig = BuildDerivedTypeConfigs(reference.Method); + return new MappingConfiguration(enumConfig, propertiesConfig, derivedTypesConfig); + } + + private IReadOnlyCollection BuildDerivedTypeConfigs(IMethodSymbol method) + { + return _dataAccessor + .Access(method) + .Concat(_dataAccessor.Access, DerivedTypeMappingConfiguration>(method)) + .ToList(); + } + + private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol method) + { + var ignoredSourceProperties = _dataAccessor + .Access(method) + .Select(x => x.Source) + .WhereNotNull() + .ToList(); + var ignoredTargetProperties = _dataAccessor + .Access(method) + .Select(x => x.Target) + .WhereNotNull() + .ToList(); + var explicitMappings = _dataAccessor.Access(method).ToList(); + var ignoreObsolete = _dataAccessor.Access(method).FirstOrDefault() is not { } methodIgnore + ? _defaultConfiguration.Properties.IgnoreObsoleteMembersStrategy + : methodIgnore.IgnoreObsoleteStrategy; + + return new PropertiesMappingConfiguration(ignoredSourceProperties, ignoredTargetProperties, explicitMappings, ignoreObsolete); + } + + private EnumMappingConfiguration BuildEnumConfig(MappingConfigurationReference configRef) + { + if (configRef.Method == null || !configRef.Source.IsEnum() && !configRef.Target.IsEnum()) + return _defaultConfiguration.Enum; + + var configData = _dataAccessor.AccessFirstOrDefault(configRef.Method); + var explicitMappings = _dataAccessor.Access(configRef.Method).ToList(); + var ignoredSources = _dataAccessor + .Access(configRef.Method) + .Select(x => x.Value) + .ToList(); + var ignoredTargets = _dataAccessor + .Access(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 + ); + } +} diff --git a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs index 464aec4cdf..cba3e233a4 100644 --- a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs @@ -28,7 +28,8 @@ public DescriptorBuilder( ClassDeclarationSyntax mapperSyntax, INamedTypeSymbol mapperSymbol, WellKnownTypes wellKnownTypes, - SymbolAccessor symbolAccessor + SymbolAccessor symbolAccessor, + MapperConfiguration? defaultMapperConfiguration ) { _mapperDescriptor = new MapperDescriptor(mapperSyntax, mapperSymbol, _methodNameBuilder); @@ -38,7 +39,7 @@ SymbolAccessor symbolAccessor var attributeAccessor = new AttributeDataAccessor(symbolAccessor); _builderContext = new SimpleMappingBuilderContext( compilation, - new MapperConfiguration(attributeAccessor, mapperSymbol), + new MapperConfigurationReader(attributeAccessor, mapperSymbol, defaultMapperConfiguration), wellKnownTypes, _symbolAccessor, attributeAccessor, diff --git a/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs index b52e44d68b..ddf77f739e 100644 --- a/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/SimpleMappingBuilderContext.cs @@ -12,11 +12,11 @@ public class SimpleMappingBuilderContext { private readonly MapperDescriptor _descriptor; private readonly List _diagnostics; - private readonly MapperConfiguration _configuration; + private readonly MapperConfigurationReader _configurationReader; public SimpleMappingBuilderContext( Compilation compilation, - MapperConfiguration configuration, + MapperConfigurationReader configurationReader, WellKnownTypes types, SymbolAccessor symbolAccessor, AttributeDataAccessor attributeAccessor, @@ -29,7 +29,7 @@ ExistingTargetMappingBuilder existingTargetMappingBuilder Compilation = compilation; Types = types; SymbolAccessor = symbolAccessor; - _configuration = configuration; + _configurationReader = configurationReader; _descriptor = descriptor; _diagnostics = diagnostics; MappingBuilder = mappingBuilder; @@ -40,7 +40,7 @@ ExistingTargetMappingBuilder existingTargetMappingBuilder protected SimpleMappingBuilderContext(SimpleMappingBuilderContext ctx) : this( ctx.Compilation, - ctx._configuration, + ctx._configurationReader, ctx.Types, ctx.SymbolAccessor, ctx.AttributeAccessor, @@ -52,7 +52,7 @@ protected SimpleMappingBuilderContext(SimpleMappingBuilderContext ctx) public Compilation Compilation { get; } - public MapperAttribute MapperConfiguration => _configuration.Mapper; + public MapperAttribute MapperConfiguration => _configurationReader.Mapper; public WellKnownTypes Types { get; } @@ -74,5 +74,5 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, ISymbol? location, _diagnostics.Add(Diagnostic.Create(descriptor, nodeLocation ?? _descriptor.Syntax.GetLocation(), messageArgs)); } - protected MappingConfiguration ReadConfiguration(MappingConfigurationReference configRef) => _configuration.BuildFor(configRef); + protected MappingConfiguration ReadConfiguration(MappingConfigurationReference configRef) => _configurationReader.BuildFor(configRef); } diff --git a/src/Riok.Mapperly/Helpers/MapperConfigurationBuilder.cs b/src/Riok.Mapperly/Helpers/MapperConfigurationBuilder.cs new file mode 100644 index 0000000000..aa9957cda2 --- /dev/null +++ b/src/Riok.Mapperly/Helpers/MapperConfigurationBuilder.cs @@ -0,0 +1,52 @@ +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Configuration; + +namespace Riok.Mapperly.Helpers; + +public static class MapperConfigurationBuilder +{ + public static MapperAttribute Merge(MapperConfiguration mapperConfiguration, MapperConfiguration? defaultMapperConfiguration) + { + var mapper = new MapperAttribute(); + mapper.PropertyNameMappingStrategy = + mapperConfiguration.PropertyNameMappingStrategy + ?? defaultMapperConfiguration?.PropertyNameMappingStrategy + ?? mapper.PropertyNameMappingStrategy; + + mapper.EnumMappingStrategy = + mapperConfiguration.EnumMappingStrategy ?? defaultMapperConfiguration?.EnumMappingStrategy ?? mapper.EnumMappingStrategy; + + mapper.EnumMappingIgnoreCase = + mapperConfiguration.EnumMappingIgnoreCase ?? defaultMapperConfiguration?.EnumMappingIgnoreCase ?? mapper.EnumMappingIgnoreCase; + + mapper.ThrowOnMappingNullMismatch = + mapperConfiguration.ThrowOnMappingNullMismatch + ?? defaultMapperConfiguration?.ThrowOnMappingNullMismatch + ?? mapper.ThrowOnMappingNullMismatch; + + mapper.ThrowOnPropertyMappingNullMismatch = + mapperConfiguration.ThrowOnPropertyMappingNullMismatch + ?? defaultMapperConfiguration?.ThrowOnPropertyMappingNullMismatch + ?? mapper.ThrowOnPropertyMappingNullMismatch; + + mapper.AllowNullPropertyAssignment = + mapperConfiguration.AllowNullPropertyAssignment + ?? defaultMapperConfiguration?.AllowNullPropertyAssignment + ?? mapper.AllowNullPropertyAssignment; + + mapper.UseDeepCloning = mapperConfiguration.UseDeepCloning ?? defaultMapperConfiguration?.UseDeepCloning ?? mapper.UseDeepCloning; + + mapper.EnabledConversions = + mapperConfiguration.EnabledConversions ?? defaultMapperConfiguration?.EnabledConversions ?? mapper.EnabledConversions; + + mapper.UseReferenceHandling = + mapperConfiguration.UseReferenceHandling ?? defaultMapperConfiguration?.UseReferenceHandling ?? mapper.UseReferenceHandling; + + mapper.IgnoreObsoleteMembersStrategy = + mapperConfiguration.IgnoreObsoleteMembersStrategy + ?? defaultMapperConfiguration?.IgnoreObsoleteMembersStrategy + ?? mapper.IgnoreObsoleteMembersStrategy; + + return mapper; + } +} diff --git a/src/Riok.Mapperly/MapperGenerator.cs b/src/Riok.Mapperly/MapperGenerator.cs index 9ab0285259..2280eade24 100644 --- a/src/Riok.Mapperly/MapperGenerator.cs +++ b/src/Riok.Mapperly/MapperGenerator.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Configuration; using Riok.Mapperly.Descriptors; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Emit; @@ -20,6 +21,7 @@ public class MapperGenerator : IIncrementalGenerator public const string ReportDiagnosticsStep = "Diagnostics"; public static readonly string MapperAttributeName = typeof(MapperAttribute).FullName!; + public static readonly string MapperDefaultsAttributeName = typeof(MapperDefaultsAttribute).FullName!; public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -71,6 +73,7 @@ CancellationToken cancellationToken var diagnostics = new List(); var members = new List(); + var defaultConfiguration = ReadDefaultConfiguration(wellKnownTypes, compilation); foreach (var mapperSyntax in mappers.Distinct()) { @@ -83,7 +86,14 @@ CancellationToken cancellationToken if (!symbolAccessor.HasAttribute(mapperSymbol)) continue; - var builder = new DescriptorBuilder(compilation, mapperSyntax, mapperSymbol, wellKnownTypes, symbolAccessor); + var builder = new DescriptorBuilder( + compilation, + mapperSyntax, + mapperSymbol, + wellKnownTypes, + symbolAccessor, + defaultConfiguration + ); var (descriptor, descriptorDiagnostics) = builder.Build(); diagnostics.AddRange(descriptorDiagnostics); @@ -109,4 +119,16 @@ private static ImmutableEquatableArray BuildCompilationDiagnostics(C return ImmutableEquatableArray.Empty(); } + + private static MapperConfiguration? ReadDefaultConfiguration(WellKnownTypes wellKnownTypes, Compilation compilation) + { + var mapperDefaultsAttributeSymbol = wellKnownTypes.TryGet(MapperDefaultsAttributeName); + var mapperDefaultsAttribute = compilation.Assembly + .GetAttributes() + .FirstOrDefault(x => SymbolEqualityComparer.Default.Equals(x.AttributeClass, mapperDefaultsAttributeSymbol)); + + return mapperDefaultsAttribute != null + ? AttributeDataAccessor.Access(mapperDefaultsAttribute) + : null; + } } diff --git a/test/Riok.Mapperly.Tests/Helpers/MapperConfigurationBuilderTest.cs b/test/Riok.Mapperly.Tests/Helpers/MapperConfigurationBuilderTest.cs new file mode 100644 index 0000000000..bf38e7b88b --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/MapperConfigurationBuilderTest.cs @@ -0,0 +1,90 @@ +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Configuration; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +public class MapperConfigurationBuilderTest +{ + [Fact] + public void ShouldMergeMapperConfigurations() + { + var mapperConfiguration = NewMapperConfiguration(); + var defaultMapperConfiguration = new MapperConfiguration + { + PropertyNameMappingStrategy = PropertyNameMappingStrategy.CaseInsensitive, + EnumMappingStrategy = EnumMappingStrategy.ByValue, + EnumMappingIgnoreCase = false, + ThrowOnMappingNullMismatch = false, + ThrowOnPropertyMappingNullMismatch = false, + AllowNullPropertyAssignment = false, + UseDeepCloning = false, + EnabledConversions = MappingConversionType.Dictionary, + UseReferenceHandling = false, + IgnoreObsoleteMembersStrategy = IgnoreObsoleteMembersStrategy.Target, + }; + + var mapper = MapperConfigurationBuilder.Merge(mapperConfiguration, defaultMapperConfiguration); + mapper.PropertyNameMappingStrategy.Should().Be(PropertyNameMappingStrategy.CaseSensitive); + mapper.EnumMappingStrategy.Should().Be(EnumMappingStrategy.ByName); + mapper.EnumMappingIgnoreCase.Should().BeTrue(); + mapper.ThrowOnMappingNullMismatch.Should().BeTrue(); + mapper.ThrowOnPropertyMappingNullMismatch.Should().BeTrue(); + mapper.AllowNullPropertyAssignment.Should().BeTrue(); + mapper.UseDeepCloning.Should().BeTrue(); + mapper.EnabledConversions.Should().Be(MappingConversionType.Constructor); + mapper.UseReferenceHandling.Should().BeTrue(); + mapper.IgnoreObsoleteMembersStrategy.Should().Be(IgnoreObsoleteMembersStrategy.Source); + } + + [Fact] + public void ShouldMergeMapperConfigurationsWithEmptyDefaultMapperConfiguration() + { + var mapperConfiguration = NewMapperConfiguration(); + var mapper = MapperConfigurationBuilder.Merge(mapperConfiguration, null); + mapper.PropertyNameMappingStrategy.Should().Be(PropertyNameMappingStrategy.CaseSensitive); + mapper.EnumMappingStrategy.Should().Be(EnumMappingStrategy.ByName); + mapper.EnumMappingIgnoreCase.Should().BeTrue(); + mapper.ThrowOnMappingNullMismatch.Should().BeTrue(); + mapper.ThrowOnPropertyMappingNullMismatch.Should().BeTrue(); + mapper.AllowNullPropertyAssignment.Should().BeTrue(); + mapper.UseDeepCloning.Should().BeTrue(); + mapper.EnabledConversions.Should().Be(MappingConversionType.Constructor); + mapper.UseReferenceHandling.Should().BeTrue(); + mapper.IgnoreObsoleteMembersStrategy.Should().Be(IgnoreObsoleteMembersStrategy.Source); + } + + [Fact] + public void ShouldMergeMapperConfigurationsWithEmptyMapperConfiguration() + { + var mapperConfiguration = new MapperConfiguration(); + var defaultMapperConfiguration = new MapperConfiguration + { + PropertyNameMappingStrategy = PropertyNameMappingStrategy.CaseInsensitive, + EnumMappingStrategy = EnumMappingStrategy.ByName, + EnumMappingIgnoreCase = true, + }; + + var mapper = MapperConfigurationBuilder.Merge(mapperConfiguration, defaultMapperConfiguration); + mapper.PropertyNameMappingStrategy.Should().Be(PropertyNameMappingStrategy.CaseInsensitive); + mapper.EnumMappingStrategy.Should().Be(EnumMappingStrategy.ByName); + mapper.EnumMappingIgnoreCase.Should().BeTrue(); + } + + private MapperConfiguration NewMapperConfiguration() + { + return new MapperConfiguration + { + PropertyNameMappingStrategy = PropertyNameMappingStrategy.CaseSensitive, + EnumMappingStrategy = EnumMappingStrategy.ByName, + EnumMappingIgnoreCase = true, + ThrowOnMappingNullMismatch = true, + ThrowOnPropertyMappingNullMismatch = true, + AllowNullPropertyAssignment = true, + UseDeepCloning = true, + EnabledConversions = MappingConversionType.Constructor, + UseReferenceHandling = true, + IgnoreObsoleteMembersStrategy = IgnoreObsoleteMembersStrategy.Source, + }; + } +} diff --git a/test/Riok.Mapperly.Tests/Mapping/MapperTest.cs b/test/Riok.Mapperly.Tests/Mapping/MapperTest.cs index 9d475a5d14..fe70160efa 100644 --- a/test/Riok.Mapperly.Tests/Mapping/MapperTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/MapperTest.cs @@ -125,4 +125,26 @@ public void LanguageLevelLower9ShouldDiagnostic() ) .HaveAssertedAllDiagnostics(); } + + [Fact] + public Task AssemblyAttributeShouldWork() + { + var source = TestSourceBuilder.CSharp( + """ + using Riok.Mapperly.Abstractions; + + [assembly: MapperDefaultsAttribute(EnumMappingIgnoreCase = true)] + [Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] + public partial class MyMapper + { + partial E2 Map(E1 source); + } + + enum E1 { value1 } + enum E2 { Value1 } + """ + ); + + return TestHelper.VerifyGenerator(source); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/MapperTest.AssemblyAttributeShouldWork#MyMapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/MapperTest.AssemblyAttributeShouldWork#MyMapper.g.verified.cs new file mode 100644 index 0000000000..af046a7d3c --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/MapperTest.AssemblyAttributeShouldWork#MyMapper.g.verified.cs @@ -0,0 +1,14 @@ +//HintName: MyMapper.g.cs +// +#nullable enable +public partial class MyMapper +{ + private partial global::E2 Map(global::E1 source) + { + return source switch + { + global::E1.value1 => global::E2.Value1, + _ => throw new System.ArgumentOutOfRangeException(nameof(source), source, "The value of enum E1 is not supported"), + }; + } +}