Skip to content

Commit

Permalink
feat: Add option to specify the format provider or culture of a prope…
Browse files Browse the repository at this point in the history
…rty (#929)
  • Loading branch information
latonz authored Nov 22, 2023
1 parent 8ee92f4 commit 42d3e26
Show file tree
Hide file tree
Showing 32 changed files with 646 additions and 53 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ indent_size = 2

# markdown
[*.{md,mdx}]
indent_size = 2
indent_size = unset
trim_trailing_whitespace = false

# Verify settings
Expand Down
39 changes: 38 additions & 1 deletion docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ To enforce strict enum mappings set `RMG037` and `RMG038` to error, see [strict

### String format

The string format passed to `ToString` calls when converting to a string can be customized
The string format passed to `ToString` calls when converting to a string (using `IFormattable`) can be customized
by using the `StringFormat` property of the `MapPropertyAttribute`.

```csharp
Expand All @@ -243,6 +243,43 @@ public partial class CarMapper
}
```

### String format provider & culture

To customize the format provider / culture to be used by Mapperly when calling `ToString` (using `IFormattable`)
format providers can be used.
A format provider can be provided to Mapperly by simply annotating a field or property within the Mapper with the `FormatProviderAttribute`.
The field/property need to return a type implementing `System.IFormatProvider`.
Format providers can be referenced by the name of the property / field in `MapPropertyAttribute.FormatProvider`.
A format provider can be marked as default (set the default property of the `FormatProviderAttribute` to true).
A default format provider is used for all `ToString` conversions when the source implements `System.IFormattable`.
In a mapper only one format provider can be marked as default.

```csharp
[Mapper]
public partial class CarMapper
{
// highlight-start
[FormatProvider(Default = true)]
private IFormatProvider CurrentCulture => CultureInfo.CurrentCulture;
// highlight-end
// highlight-start
[FormatProvider]
private readonly IFormatProvider _enCulture = CultureInfo.GetCultureInfo("en-US");
// highlight-end
// highlight-start
[MapProperty(nameof(Car.LocalPrice), nameof(CarDto.LocalPrice), StringFormat = "C")]
[MapProperty(nameof(Car.ListPrice), nameof(CarDto.ListPrice), StringFormat = "C", FormatProvider = nameof(_enCulture)]
// highlight-end
public partial CarDto MapCar(Car car);

// generates
target.LocalPrice = source.LocalPrice.ToString("C", CurrentCulture);
target.ListPrice = source.ListPrice.ToString("C", _enCulture);
}
```

## Default Mapper configuration

The `MapperDefaultsAttribute` allows to set default configurations applied to all mappers on the assembly level.
Expand Down
7 changes: 4 additions & 3 deletions docs/docs/contributing/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ The `DescriptorBuilder` does this by following this process:
2. Extracting user implemented and user defined mapping methods.
It instantiates a `User*Mapping` (eg. `UserDefinedNewInstanceMethodMapping`) for each discovered mapping method and adds it to the queue of mappings to work on.
3. Extracting user implemented object factories
4. Extracting external mappings
5. For each mapping in the queue the `DescriptorBuilder` tries to build its implementation bodies.
4. Extracting user implemented format providers
5. Extracting external mappings
6. For each mapping in the queue the `DescriptorBuilder` tries to build its implementation bodies.
This is done by a so called `*MappingBodyBuilder`.
A mapping body builder tries to map each property from the source to the target.
To do this, it asks the `DescriptorBuilder` to create mappings for the according types.
To create a mapping from one type to another, the `DescriptorBuilder` loops through a set of `*MappingBuilder`s.
Each of the mapping builders try to create a mapping (an `ITypeMapping` implementation) for the asked type mapping by using
one approach on how to map types (eg. an explicit cast is implemented by the `ExplicitCastMappingBuilder`).
These mappings are queued in the queue of mappings which need the body to be built (currently body builders are only used for object to object (property-based) mappings).
6. The `SourceEmitter` emits the code described by the `MapperDescriptor` and all its mappings.
7. The `SourceEmitter` emits the code described by the `MapperDescriptor` and all its mappings.
The syntax objects are created by using `SyntaxFactory` and `SyntaxFactoryHelper`.
The `SyntaxFactoryHelper` tries to simplify creating formatted syntax trees.
If indentation is needed,
Expand Down
16 changes: 16 additions & 0 deletions src/Riok.Mapperly.Abstractions/FormatProviderAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Marks a property or field as a format provider.
/// A format provider needs to be of a type which implements <see cref="IFormatProvider"/> and needs to have a getter.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class FormatProviderAttribute : Attribute
{
/// <summary>
/// If set to true, this format provider acts as a default format provider
/// and is used for all <see cref="IFormattable"/> conversions without an explicit <see cref="MapPropertyAttribute.FormatProvider"/> set.
/// Only one <see cref="FormatProviderAttribute"/> in a Mapper can be set to <c>true</c>.
/// </summary>
public bool Default { get; set; }
}
9 changes: 8 additions & 1 deletion src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ public MapPropertyAttribute(string[] source, string[] target)
public IReadOnlyCollection<string> Target { get; }

/// <summary>
/// Gets or sets the format of the <c>ToString</c> conversion.
/// Gets or sets the format of the <c>ToString</c> conversion (implementing <see cref="IFormattable" />).
/// </summary>
public string? StringFormat { get; set; }

/// <summary>
/// Gets or sets the name of a format provider field or property to be used for conversions accepting a format provider (implementing <see cref="IFormattable"/>).
/// If <c>null</c> the default format provider (annotated with <see cref="FormatProviderAttribute"/> and <see cref="FormatProviderAttribute.Default"/> <c>true</c>)
/// or none (if no default format provider is provided) is used.
/// </summary>
public string? FormatProvider { get; set; }

/// <summary>
/// Gets the full name of the target property path.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,9 @@ Riok.Mapperly.Abstractions.MemberVisibility.Internal = 4 -> Riok.Mapperly.Abstra
Riok.Mapperly.Abstractions.MemberVisibility.Private = 16 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Protected = 8 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Public = 2 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.FormatProviderAttribute
Riok.Mapperly.Abstractions.FormatProviderAttribute.Default.get -> bool
Riok.Mapperly.Abstractions.FormatProviderAttribute.Default.set -> void
Riok.Mapperly.Abstractions.FormatProviderAttribute.FormatProviderAttribute() -> void
Riok.Mapperly.Abstractions.MapPropertyAttribute.FormatProvider.get -> string?
Riok.Mapperly.Abstractions.MapPropertyAttribute.FormatProvider.set -> void
5 changes: 4 additions & 1 deletion src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ RMG051 | Mapper | Warning | Invalid ignore source member found, nested ignor
RMG052 | Mapper | Warning | Invalid ignore target member found, nested ignores are not supported
RMG053 | Mapper | Error | The flag MemberVisibility.Accessible cannot be disabled, this feature requires .NET 8.0 or greater
RMG054 | Mapper | Error | Mapper class containing 'static partial' method must not have any instance methods
RMG055 | Mapper | Error | The source type does not implement IFormattable, string format cannot be applied
RMG055 | Mapper | Error | The source type does not implement IFormattable, string format and format provider cannot be applied
RMG056 | Mapper | Error | Invalid format provider signature
RMG057 | Mapper | Error | Format provider not found
RMG058 | Mapper | Error | Multiple default format providers found, only one is allowed

### Removed Rules
Rule ID | Category | Severity | Notes
Expand Down
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public class AttributeDataAccessor(SymbolAccessor symbolAccessor)
private const string NameOfOperatorName = "nameof";
private const char FullNameOfPrefix = '@';

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

public TData AccessSingle<TAttribute, TData>(ISymbol symbol)
where TAttribute : Attribute
where TData : notnull => Access<TAttribute, TData>(symbol).Single();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ public record PropertyMappingConfiguration(StringMemberPath Source, StringMember
{
public string? StringFormat { get; set; }

public TypeMappingConfiguration ToTypeMappingConfiguration() => new(StringFormat);
public string? FormatProvider { get; set; }

public TypeMappingConfiguration ToTypeMappingConfiguration() => new(StringFormat, FormatProvider);
}
12 changes: 10 additions & 2 deletions src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Riok.Mapperly.Abstractions.ReferenceHandling;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.ExternalMappings;
using Riok.Mapperly.Descriptors.FormatProviders;
using Riok.Mapperly.Descriptors.MappingBodyBuilders;
using Riok.Mapperly.Descriptors.MappingBuilders;
using Riok.Mapperly.Descriptors.ObjectFactories;
Expand Down Expand Up @@ -67,7 +68,8 @@ MapperConfiguration defaultMapperConfiguration

// ExtractObjectFactories needs to be called after ExtractUserMappings due to configuring mapperDescriptor.Static
var objectFactories = ExtractObjectFactories();
EnqueueUserMappings(objectFactories);
var formatProviders = ExtractFormatProviders();
EnqueueUserMappings(objectFactories, formatProviders);
ExtractExternalMappings();
_mappingBodyBuilder.BuildMappingBodies(cancellationToken);
BuildMappingMethodNames();
Expand Down Expand Up @@ -144,13 +146,14 @@ private ObjectFactoryCollection ExtractObjectFactories()
return ObjectFactoryBuilder.ExtractObjectFactories(_builderContext, _mapperDescriptor.Symbol);
}

private void EnqueueUserMappings(ObjectFactoryCollection objectFactories)
private void EnqueueUserMappings(ObjectFactoryCollection objectFactories, FormatProviderCollection formatProviders)
{
foreach (var userMapping in _mappings.UserMappings)
{
var ctx = new MappingBuilderContext(
_builderContext,
objectFactories,
formatProviders,
userMapping.Method,
new TypeMappingKey(userMapping.SourceType, userMapping.TargetType)
);
Expand All @@ -167,6 +170,11 @@ private void ExtractExternalMappings()
}
}

private FormatProviderCollection ExtractFormatProviders()
{
return FormatProviderBuilder.ExtractFormatProviders(_builderContext, _mapperDescriptor.Symbol);
}

private void BuildMappingMethodNames()
{
foreach (var methodMapping in _mappings.MethodMappings)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Microsoft.CodeAnalysis;

namespace Riok.Mapperly.Descriptors.FormatProviders;

public record FormatProvider(string Name, bool Default, ISymbol Symbol);
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Diagnostics;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols;

namespace Riok.Mapperly.Descriptors.FormatProviders;

public static class FormatProviderBuilder
{
public static FormatProviderCollection ExtractFormatProviders(SimpleMappingBuilderContext ctx, ITypeSymbol mapperSymbol)
{
var formatProviders = mapperSymbol
.GetMembers()
.Where(x => ctx.SymbolAccessor.HasAttribute<FormatProviderAttribute>(x))
.Select(x => BuildFormatProvider(ctx, x))
.WhereNotNull()
.ToList();

var defaultFormatProviderCandidates = formatProviders.Where(x => x.Default).Take(2).ToList();
if (defaultFormatProviderCandidates.Count > 1)
{
ctx.ReportDiagnostic(DiagnosticDescriptors.MultipleDefaultFormatProviders, defaultFormatProviderCandidates[1].Symbol);
}

var formatProvidersByName = formatProviders.GroupBy(x => x.Name).ToDictionary(x => x.Key, x => x.Single());
return new FormatProviderCollection(formatProvidersByName, defaultFormatProviderCandidates.FirstOrDefault());
}

private static FormatProvider? BuildFormatProvider(SimpleMappingBuilderContext ctx, ISymbol symbol)
{
var memberSymbol = MappableMember.Create(ctx.SymbolAccessor, symbol);
if (memberSymbol == null)
return null;

if (!memberSymbol.CanGet || symbol.IsStatic != ctx.Static || !memberSymbol.Type.Implements(ctx.Types.Get<IFormatProvider>()))
{
ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFormatProviderSignature, symbol, symbol.Name);
return null;
}

var attribute = ctx.AttributeAccessor.AccessSingle<FormatProviderAttribute>(symbol);
return new FormatProvider(symbol.Name, attribute.Default, symbol);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Riok.Mapperly.Descriptors.FormatProviders;

public class FormatProviderCollection(
IReadOnlyDictionary<string, FormatProvider> formatProvidersByName,
FormatProvider? defaultFormatProvider
)
{
public FormatProvider? Get(string? reference)
{
return reference == null ? defaultFormatProvider : formatProvidersByName.GetValueOrDefault(reference);
}
}
17 changes: 16 additions & 1 deletion src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.Enumerables;
using Riok.Mapperly.Descriptors.FormatProviders;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.ExistingTarget;
using Riok.Mapperly.Descriptors.Mappings.UserMappings;
Expand All @@ -19,19 +20,21 @@ public class MappingBuilderContext : SimpleMappingBuilderContext
public MappingBuilderContext(
SimpleMappingBuilderContext parentCtx,
ObjectFactoryCollection objectFactories,
FormatProviderCollection formatProviders,
IMethodSymbol? userSymbol,
TypeMappingKey mappingKey
)
: base(parentCtx)
{
ObjectFactories = objectFactories;
FormatProviders = formatProviders;
UserSymbol = userSymbol;
MappingKey = mappingKey;
Configuration = ReadConfiguration(new MappingConfigurationReference(UserSymbol, mappingKey.Source, mappingKey.Target));
}

protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSymbol, TypeMappingKey mappingKey, bool clearDerivedTypes)
: this(ctx, ctx.ObjectFactories, userSymbol, mappingKey)
: this(ctx, ctx.ObjectFactories, ctx.FormatProviders, userSymbol, mappingKey)
{
if (clearDerivedTypes)
{
Expand All @@ -57,6 +60,7 @@ protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSy
public virtual bool IsExpression => false;

public ObjectFactoryCollection ObjectFactories { get; }
public FormatProviderCollection FormatProviders { get; }

/// <inheritdoc cref="MappingBuilders.MappingBuilder.UserMappings"/>
public IReadOnlyCollection<IUserMapping> UserMappings => MappingBuilder.UserMappings;
Expand Down Expand Up @@ -212,6 +216,17 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, params object[] me
public NullFallbackValue GetNullFallbackValue(ITypeSymbol? targetType = null) =>
GetNullFallbackValue(targetType ?? Target, MapperConfiguration.ThrowOnMappingNullMismatch);

public FormatProvider? GetFormatProvider(string? formatProviderName)
{
var formatProvider = FormatProviders.Get(formatProviderName);
if (formatProviderName != null && formatProvider == null)
{
ReportDiagnostic(DiagnosticDescriptors.FormatProviderNotFound, formatProviderName);
}

return formatProvider;
}

protected virtual NullFallbackValue GetNullFallbackValue(ITypeSymbol targetType, bool throwOnMappingNullMismatch)
{
if (targetType.IsNullable())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public static class ToStringMappingBuilder
if (ctx.Target.SpecialType != SpecialType.System_String)
return null;

if (ctx.MappingKey.Configuration.StringFormat == null)
var formatProvider = ctx.GetFormatProvider(ctx.MappingKey.Configuration.FormatProviderName);
if (ctx.MappingKey.Configuration.StringFormat == null && formatProvider == null)
return new ToStringMapping(ctx.Source, ctx.Target);

if (!ctx.Source.Implements(ctx.Types.Get<IFormattable>()))
Expand All @@ -25,6 +26,6 @@ public static class ToStringMappingBuilder
return new ToStringMapping(ctx.Source, ctx.Target);
}

return new ToStringMapping(ctx.Source, ctx.Target, ctx.MappingKey.Configuration.StringFormat);
return new ToStringMapping(ctx.Source, ctx.Target, ctx.MappingKey.Configuration.StringFormat, formatProvider?.Name);
}
}
9 changes: 5 additions & 4 deletions src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper;

namespace Riok.Mapperly.Descriptors.Mappings;
Expand All @@ -11,15 +12,15 @@ namespace Riok.Mapperly.Descriptors.Mappings;
/// target = source.ToString();
/// </code>
/// </summary>
public class ToStringMapping(ITypeSymbol sourceType, ITypeSymbol targetType, string? stringFormat = null)
public class ToStringMapping(ITypeSymbol sourceType, ITypeSymbol targetType, string? stringFormat = null, string? formatProviderName = null)
: SourceObjectMethodMapping(sourceType, targetType, nameof(ToString))
{
protected override IEnumerable<ExpressionSyntax> BuildArguments(TypeMappingBuildContext ctx)
{
if (stringFormat == null)
if (stringFormat == null && formatProviderName == null)
yield break;

yield return StringLiteral(stringFormat);
yield return NullLiteral();
yield return stringFormat == null ? NullLiteral() : StringLiteral(stringFormat);
yield return formatProviderName == null ? NullLiteral() : IdentifierName(formatProviderName);
}
}
5 changes: 3 additions & 2 deletions src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ namespace Riok.Mapperly.Descriptors;
/// Configuration for a type mapping.
/// Eg. the format to apply to `ToString` calls.
/// </summary>
/// <param name="StringFormat">The format to apply to `ToString` calls.</param>
public record TypeMappingConfiguration(string? StringFormat = null)
/// <param name="StringFormat">The format to apply to <see cref="IFormattable"/>.</param>
/// <param name="FormatProviderName">The name of the format provider to apply to <see cref="IFormattable"/>.</param>
public record TypeMappingConfiguration(string? StringFormat = null, string? FormatProviderName = null)
{
public static readonly TypeMappingConfiguration Default = new();
}
Loading

0 comments on commit 42d3e26

Please sign in to comment.