diff --git a/docs/docs/configuration/mapper.mdx b/docs/docs/configuration/mapper.mdx index 167a05e5cd..7fac0bfb6f 100644 --- a/docs/docs/configuration/mapper.mdx +++ b/docs/docs/configuration/mapper.mdx @@ -216,7 +216,7 @@ the `RequiredMappingStrategy` can be used. // highlight-start [MapperRequiredMapping(RequiredMappingStrategy.Source)] // highlight-end - public partial CarDto MapMake(Car make); + public partial CarDto MapCar(Car car); } ``` @@ -227,6 +227,22 @@ the `RequiredMappingStrategy` can be used. To enforce strict enum mappings set `RMG037` and `RMG038` to error, see [strict enum mappings](./enum.mdx#strict-enum-mappings). +### String format + +The string format passed to `ToString` calls when converting to a string can be customized +by using the `StringFormat` property of the `MapPropertyAttribute`. + +```csharp +[Mapper] +public partial class CarMapper +{ + // highlight-start + [MapProperty(nameof(Car.Price), nameof(CarDto.Price), StringFormat = "C")] + // highlight-end + public partial CarDto MapCar(Car car); +} +``` + ## Default Mapper configuration The `MapperDefaultsAttribute` allows to set default configurations applied to all mappers on the assembly level. diff --git a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs index f3483d5b9e..2887b8228f 100644 --- a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs @@ -43,6 +43,11 @@ public MapPropertyAttribute(string[] source, string[] target) /// public IReadOnlyCollection Target { get; } + /// + /// Gets or sets the format of the ToString conversion. + /// + public string? StringFormat { get; set; } + /// /// Gets the full name of the target property path. /// diff --git a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt index d001489589..cb33fe160d 100644 --- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt +++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt @@ -55,6 +55,8 @@ Riok.Mapperly.Abstractions.MapPropertyAttribute.Source.get -> System.Collections Riok.Mapperly.Abstractions.MapPropertyAttribute.SourceFullName.get -> string! Riok.Mapperly.Abstractions.MapPropertyAttribute.Target.get -> System.Collections.Generic.IReadOnlyCollection! Riok.Mapperly.Abstractions.MapPropertyAttribute.TargetFullName.get -> string! +Riok.Mapperly.Abstractions.MapPropertyAttribute.StringFormat.get -> string? +Riok.Mapperly.Abstractions.MapPropertyAttribute.StringFormat.set -> void Riok.Mapperly.Abstractions.ObjectFactoryAttribute Riok.Mapperly.Abstractions.ObjectFactoryAttribute.ObjectFactoryAttribute() -> void Riok.Mapperly.Abstractions.PropertyNameMappingStrategy diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index 02a59da661..04042b272b 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -127,6 +127,7 @@ 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 ### Removed Rules Rule ID | Category | Severity | Notes diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs index 31eb60f357..cbc0e86859 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs @@ -72,7 +72,7 @@ private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol metho .Select(x => x.Target) .WhereNotNull() .ToList(); - var explicitMappings = _dataAccessor.Access(method).ToList(); + var propertyConfigurations = _dataAccessor.Access(method).ToList(); var ignoreObsolete = _dataAccessor.Access(method).FirstOrDefault() is not { } methodIgnore ? _defaultConfiguration.Properties.IgnoreObsoleteMembersStrategy : methodIgnore.IgnoreObsoleteStrategy; @@ -83,7 +83,7 @@ private PropertiesMappingConfiguration BuildPropertiesConfig(IMethodSymbol metho return new PropertiesMappingConfiguration( ignoredSourceProperties, ignoredTargetProperties, - explicitMappings, + propertyConfigurations, ignoreObsolete, requiredMapping ); diff --git a/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs index 79f56e6373..5c8c802146 100644 --- a/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs @@ -1,3 +1,10 @@ +using Riok.Mapperly.Descriptors; + namespace Riok.Mapperly.Configuration; -public record PropertyMappingConfiguration(StringMemberPath Source, StringMemberPath Target); +public record PropertyMappingConfiguration(StringMemberPath Source, StringMemberPath Target) +{ + public string? StringFormat { get; set; } + + public TypeMappingConfiguration ToTypeMappingConfiguration() => new(StringFormat); +} diff --git a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs index 9363ec38f2..3ae11aee56 100644 --- a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs @@ -126,7 +126,7 @@ private void ExtractUserMappings() firstNonStaticUserMapping = userMapping.Method; } - _mappings.Add(userMapping); + _mappings.Add(userMapping, TypeMappingConfiguration.Default); } if (_mapperDescriptor.Static && firstNonStaticUserMapping is not null) @@ -152,8 +152,7 @@ private void EnqueueUserMappings(ObjectFactoryCollection objectFactories) _builderContext, objectFactories, userMapping.Method, - userMapping.SourceType, - userMapping.TargetType + new TypeMappingKey(userMapping.SourceType, userMapping.TargetType) ); _mappings.EnqueueToBuildBody(userMapping, ctx); @@ -164,7 +163,7 @@ private void ExtractExternalMappings() { foreach (var externalMapping in ExternalMappingsExtractor.ExtractExternalMappings(_builderContext, _mapperDescriptor.Symbol)) { - _mappings.Add(externalMapping); + _mappings.Add(externalMapping, TypeMappingConfiguration.Default); } } diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs index 15c8acf77d..420127c91c 100644 --- a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs @@ -3,7 +3,6 @@ using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.ExistingTarget; using Riok.Mapperly.Descriptors.Mappings.UserMappings; -using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors; @@ -16,16 +15,11 @@ public class InlineExpressionMappingBuilderContext : MappingBuilderContext private readonly MappingCollection _inlineExpressionMappings; private readonly MappingBuilderContext _parentContext; - public InlineExpressionMappingBuilderContext(MappingBuilderContext ctx, ITypeSymbol sourceType, ITypeSymbol targetType) - : this(ctx, (ctx.FindMapping(sourceType, targetType) as IUserMapping)?.Method, sourceType, targetType) { } + public InlineExpressionMappingBuilderContext(MappingBuilderContext ctx, TypeMappingKey mappingKey) + : this(ctx, (ctx.FindMapping(mappingKey) as IUserMapping)?.Method, mappingKey) { } - private InlineExpressionMappingBuilderContext( - MappingBuilderContext ctx, - IMethodSymbol? userSymbol, - ITypeSymbol source, - ITypeSymbol target - ) - : base(ctx, userSymbol, source, target, false) + private InlineExpressionMappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSymbol, TypeMappingKey mappingKey) + : base(ctx, userSymbol, mappingKey, false) { _parentContext = ctx; _inlineExpressionMappings = new MappingCollection(); @@ -34,11 +28,10 @@ ITypeSymbol target private InlineExpressionMappingBuilderContext( InlineExpressionMappingBuilderContext ctx, IMethodSymbol? userSymbol, - ITypeSymbol source, - ITypeSymbol target, + TypeMappingKey mappingKey, bool clearDerivedTypes ) - : base(ctx, userSymbol, source, target, clearDerivedTypes) + : base(ctx, userSymbol, mappingKey, clearDerivedTypes) { _parentContext = ctx; _inlineExpressionMappings = ctx._inlineExpressionMappings; @@ -55,25 +48,24 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi && base.IsConversionEnabled(conversionType); /// - /// Tries to find an existing mapping for the provided types. + /// 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. /// Only inline expression mappings and user implemented mappings are considered. /// - /// The source type. - /// The target type. + /// The mapping key. /// The if a mapping was found or null if none was found. - public override INewInstanceMapping? FindMapping(ITypeSymbol sourceType, ITypeSymbol targetType) + public override INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) { - if (_inlineExpressionMappings.Find(sourceType, targetType) is { } mapping) + if (_inlineExpressionMappings.Find(mappingKey) is { } mapping) return mapping; // User implemented mappings are also 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 (_parentContext.FindMapping(sourceType, targetType) is UserImplementedMethodMapping userMapping) + if (_parentContext.FindMapping(mappingKey) is UserImplementedMethodMapping userMapping) { - _inlineExpressionMappings.Add(userMapping); + _inlineExpressionMappings.Add(userMapping, mappingKey.Configuration); return userMapping; } @@ -88,33 +80,29 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi /// This ensures, the configuration of the user defined method is reused. /// /// - /// The source type. - /// The target type. + /// The mapping key. /// The options, is ignored. /// public override INewInstanceMapping? FindOrBuildMapping( - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default ) { - sourceType = sourceType.UpgradeNullable(); - targetType = targetType.UpgradeNullable(); - var mapping = FindMapping(sourceType, targetType); + var mapping = FindMapping(mappingKey); if (mapping != null) return mapping; var userSymbol = options.HasFlag(MappingBuildingOptions.KeepUserSymbol) ? UserSymbol : null; - userSymbol ??= (MappingBuilder.Find(sourceType, targetType) as IUserMapping)?.Method; + userSymbol ??= (MappingBuilder.Find(mappingKey) as IUserMapping)?.Method; // unset MarkAsReusable and KeepUserSymbol as they have special handling for inline mappings options &= ~(MappingBuildingOptions.MarkAsReusable | MappingBuildingOptions.KeepUserSymbol); - mapping = BuildMapping(userSymbol, sourceType, targetType, options); + mapping = BuildMapping(userSymbol, mappingKey, options); if (mapping != null) { - _inlineExpressionMappings.Add(mapping); + _inlineExpressionMappings.Add(mapping, mappingKey.Configuration); } return mapping; @@ -123,26 +111,22 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi /// /// Existing target instance mappings are not supported. /// - /// The source type, ignored. - /// The target type, ignored. + /// The mapping key, ignored. /// The options to build a new mapping, ignored. /// null public override IExistingTargetMapping? FindOrBuildExistingTargetMapping( - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default ) => null; /// /// Existing target instance mappings are not supported. /// - /// The source type, ignored. - /// The target type, ignored. + /// The mapping key, ignored. /// The options to build a new mapping, ignored. /// null public override IExistingTargetMapping? BuildExistingTargetMapping( - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default ) => null; @@ -151,15 +135,15 @@ protected override NullFallbackValue GetNullFallbackValue(ITypeSymbol targetType protected override MappingBuilderContext ContextForMapping( IMethodSymbol? userSymbol, - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options - ) => - new InlineExpressionMappingBuilderContext( + ) + { + return new InlineExpressionMappingBuilderContext( this, userSymbol, - sourceType, - targetType, + mappingKey, options.HasFlag(MappingBuildingOptions.ClearDerivedTypes) ); + } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs index 4a80e078d7..54a3959611 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs @@ -132,22 +132,22 @@ IReadOnlyCollection memberConfigs return; } - BuildInitMemberMapping(ctx, targetMember, sourceMemberPath); + BuildInitMemberMapping(ctx, targetMember, sourceMemberPath, memberConfig); } private static void BuildInitMemberMapping( INewInstanceBuilderContext ctx, IMappableMember targetMember, - MemberPath sourcePath + MemberPath sourcePath, + PropertyMappingConfiguration? memberConfig = null ) { var targetPath = new MemberPath(new[] { targetMember }); if (!ObjectMemberMappingBodyBuilder.ValidateMappingSpecification(ctx, sourcePath, targetPath, true)) return; - var delegateMapping = - ctx.BuilderContext.FindMapping(sourcePath.MemberType, targetMember.Type) - ?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.MemberType.NonNullable(), targetMember.Type.NonNullable()); + var mappingKey = new TypeMappingKey(sourcePath.MemberType, targetMember.Type, memberConfig?.ToTypeMappingConfiguration()); + var delegateMapping = ctx.BuilderContext.FindOrBuildLooseNullableMapping(mappingKey); if (delegateMapping == null) { @@ -254,7 +254,7 @@ private static bool TryBuildConstructorMapping( var skippedOptionalParam = false; foreach (var parameter in ctor.Parameters) { - if (!TryFindConstructorParameterSourcePath(ctx, parameter, out var sourcePath)) + if (!TryFindConstructorParameterSourcePath(ctx, parameter, out var sourcePath, out var memberConfig)) { // expressions do not allow skipping of optional parameters if (!parameter.IsOptional || ctx.BuilderContext.IsExpression) @@ -266,9 +266,8 @@ private static bool TryBuildConstructorMapping( // nullability is handled inside the member mapping var paramType = parameter.Type.WithNullableAnnotation(parameter.NullableAnnotation); - var delegateMapping = - ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType) - ?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType.NonNullable()); + var typeMapping = new TypeMappingKey(sourcePath.MemberType, paramType, memberConfig?.ToTypeMappingConfiguration()); + var delegateMapping = ctx.BuilderContext.FindOrBuildLooseNullableMapping(typeMapping); if (delegateMapping == null) { @@ -311,10 +310,12 @@ private static bool TryBuildConstructorMapping( private static bool TryFindConstructorParameterSourcePath( INewInstanceBuilderContext ctx, IParameterSymbol parameter, - [NotNullWhen(true)] out MemberPath? sourcePath + [NotNullWhen(true)] out MemberPath? sourcePath, + out PropertyMappingConfiguration? memberConfig ) { sourcePath = null; + memberConfig = null; if (!ctx.MemberConfigsByRootTargetName.TryGetValue(parameter.Name, out var memberConfigs)) { @@ -338,7 +339,7 @@ out sourcePath ); } - var memberConfig = memberConfigs.First(); + memberConfig = memberConfigs.First(); if (memberConfig.Target.Path.Count > 1) { ctx.BuilderContext.ReportDiagnostic( diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs index cf19f1df8c..b7c2cb2382 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Configuration; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.MemberMappings; @@ -67,7 +68,7 @@ out HashSet mappedTargetMemberNames return false; } - if (!TryFindConstructorParameterSourcePath(ctx, targetMember, out var sourcePath)) + if (!TryFindConstructorParameterSourcePath(ctx, targetMember, out var sourcePath, out var memberConfig)) { ctx.BuilderContext.ReportDiagnostic( DiagnosticDescriptors.SourceMemberNotFound, @@ -81,10 +82,8 @@ out HashSet mappedTargetMemberNames // nullability is handled inside the member expressionMapping var paramType = targetMember.Type.WithNullableAnnotation(targetMember.NullableAnnotation); - var delegateMapping = - ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType) - ?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType.NonNullable()); - + var mappingKey = new TypeMappingKey(sourcePath.MemberType, paramType, memberConfig?.ToTypeMappingConfiguration()); + var delegateMapping = ctx.BuilderContext.FindOrBuildLooseNullableMapping(mappingKey); if (delegateMapping == null) { ctx.BuilderContext.ReportDiagnostic( @@ -132,10 +131,12 @@ out HashSet mappedTargetMemberNames private static bool TryFindConstructorParameterSourcePath( INewValueTupleBuilderContext ctx, IFieldSymbol field, - [NotNullWhen(true)] out MemberPath? sourcePath + [NotNullWhen(true)] out MemberPath? sourcePath, + out PropertyMappingConfiguration? memberConfig ) { sourcePath = null; + memberConfig = null; if (!ctx.MemberConfigsByRootTargetName.TryGetValue(field.Name, out var memberConfigs)) return TryBuildConstructorParameterSourcePath(ctx, field, out sourcePath); @@ -156,7 +157,7 @@ private static bool TryFindConstructorParameterSourcePath( ); } - var memberConfig = initMemberPaths.First(); + memberConfig = initMemberPaths.First(); if (ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(ctx.Mapping.SourceType, memberConfig.Source.Path, out sourcePath)) return true; diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs index e75e5c4f9a..b51d755fd6 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs @@ -98,7 +98,7 @@ PropertyMappingConfiguration config return; } - BuildMemberAssignmentMapping(ctx, sourceMemberPath, targetMemberPath); + BuildMemberAssignmentMapping(ctx, sourceMemberPath, targetMemberPath, config); } [SuppressMessage(" Meziantou.Analyzer", "MA0051:MethodIsTooLong")] @@ -266,7 +266,8 @@ MemberPath targetMemberPath private static void BuildMemberAssignmentMapping( IMembersContainerBuilderContext ctx, MemberPath sourceMemberPath, - MemberPath targetMemberPath + MemberPath targetMemberPath, + PropertyMappingConfiguration? memberConfig = null ) { if (TryAddExistingTargetMapping(ctx, sourceMemberPath, targetMemberPath)) @@ -276,12 +277,12 @@ MemberPath targetMemberPath return; // nullability is handled inside the member mapping - var delegateMapping = - ctx.BuilderContext.FindMapping(sourceMemberPath.Member.Type, targetMemberPath.Member.Type) - ?? ctx.BuilderContext.FindOrBuildMapping( - sourceMemberPath.Member.Type.NonNullable(), - targetMemberPath.Member.Type.NonNullable() - ); + var typeMapping = new TypeMappingKey( + sourceMemberPath.MemberType, + targetMemberPath.MemberType, + memberConfig?.ToTypeMappingConfiguration() + ); + var delegateMapping = ctx.BuilderContext.FindOrBuildLooseNullableMapping(typeMapping); // couldn't build the mapping if (delegateMapping == null) @@ -344,10 +345,8 @@ MemberPath targetMemberPath return false; } - var existingTargetMapping = ctx.BuilderContext.FindOrBuildExistingTargetMapping( - sourceMemberPath.Member.Type, - targetMemberPath.Member.Type - ); + var mappingKey = new TypeMappingKey(sourceMemberPath.MemberType, targetMemberPath.MemberType); + var existingTargetMapping = ctx.BuilderContext.FindOrBuildExistingTargetMapping(mappingKey); if (existingTargetMapping == null) return false; diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs index d8174121ac..dd3d45fc1d 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs @@ -78,7 +78,7 @@ IEnumerable childMappings .OrderByDescending(x => x.SourceType.GetInheritanceLevel()) .ThenByDescending(x => x.TargetType.GetInheritanceLevel()) .ThenBy(x => x.TargetType.IsNullable()) - .GroupBy(x => new TypeMappingKey(x, false)) + .GroupBy(x => new TypeMappingKey(x, includeNullability: false)) .Select(x => x.First()) .Select(x => new RuntimeTargetTypeMapping(x, ctx.Compilation.HasImplicitConversion(x.TargetType, ctx.Target))); mapping.AddMappings(runtimeTargetTypeMappings); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/UserMethodMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/UserMethodMappingBodyBuilder.cs index ccc7bcc264..0ab71eec89 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/UserMethodMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/UserMethodMappingBodyBuilder.cs @@ -1,6 +1,5 @@ using Riok.Mapperly.Descriptors.Mappings.UserMappings; using Riok.Mapperly.Diagnostics; -using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBodyBuilders; @@ -13,11 +12,9 @@ public static void BuildMappingBody(MappingBuilderContext ctx, UserDefinedExisti { // UserDefinedExistingTargetMethodMapping handles null already var delegateMapping = ctx.BuildExistingTargetMapping( - mapping.SourceType.NonNullable(), - mapping.TargetType.NonNullable(), + new TypeMappingKey(mapping).NonNullable(), MappingBuildingOptions.KeepUserSymbol ); - if (delegateMapping != null) { mapping.SetDelegateMapping(delegateMapping); @@ -39,8 +36,7 @@ public static void BuildMappingBody(MappingBuilderContext ctx, UserDefinedNewIns options |= MappingBuildingOptions.MarkAsReusable; } - var delegateMapping = ctx.BuildMapping(mapping.SourceType, mapping.TargetType, options); - + var delegateMapping = ctx.BuildMapping(new TypeMappingKey(mapping), options); if (delegateMapping != null) { mapping.SetDelegateMapping(delegateMapping); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 26ee3e07f9..63eefd7d0d 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -20,26 +20,18 @@ public MappingBuilderContext( SimpleMappingBuilderContext parentCtx, ObjectFactoryCollection objectFactories, IMethodSymbol? userSymbol, - ITypeSymbol source, - ITypeSymbol target + TypeMappingKey mappingKey ) : base(parentCtx) { ObjectFactories = objectFactories; - Source = source; - Target = target; UserSymbol = userSymbol; - Configuration = ReadConfiguration(new MappingConfigurationReference(UserSymbol, source, target)); + MappingKey = mappingKey; + Configuration = ReadConfiguration(new MappingConfigurationReference(UserSymbol, mappingKey.Source, mappingKey.Target)); } - protected MappingBuilderContext( - MappingBuilderContext ctx, - IMethodSymbol? userSymbol, - ITypeSymbol source, - ITypeSymbol target, - bool clearDerivedTypes - ) - : this(ctx, ctx.ObjectFactories, userSymbol, source, target) + protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSymbol, TypeMappingKey mappingKey, bool clearDerivedTypes) + : this(ctx, ctx.ObjectFactories, userSymbol, mappingKey) { if (clearDerivedTypes) { @@ -49,9 +41,11 @@ bool clearDerivedTypes public MappingConfiguration Configuration { get; } - public ITypeSymbol Source { get; } + public TypeMappingKey MappingKey { get; } - public ITypeSymbol Target { get; } + public ITypeSymbol Source => MappingKey.Source; + + public ITypeSymbol Target => MappingKey.Target; public CollectionInfos? CollectionInfos => _collectionInfos ??= CollectionInfoBuilder.Build(Types, SymbolAccessor, Source, Target); @@ -68,14 +62,12 @@ bool clearDerivedTypes public IReadOnlyCollection UserMappings => MappingBuilder.UserMappings; /// - /// Tries to find an existing mapping for the provided types. + /// Tries to find an existing mapping for the provided key. /// If none is found, null is returned. /// - /// The source type. - /// The target type. + /// The mapping key. /// The found mapping, or null if none is found. - public virtual INewInstanceMapping? FindMapping(ITypeSymbol sourceType, ITypeSymbol targetType) => - MappingBuilder.Find(sourceType.UpgradeNullable(), targetType.UpgradeNullable()); + public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) => MappingBuilder.Find(mappingKey); /// /// Tries to find an existing mapping for the provided types. @@ -83,35 +75,75 @@ bool clearDerivedTypes /// If no mapping is possible for the provided types, /// null is returned. /// If a new mapping is created, it is added to the mapping descriptor - /// and returned in further calls. - /// No configuration / user symbol is passed. + /// and returned in further calls to this method. + /// No user symbol is passed. /// /// The source type. /// The target type. /// The mapping building options. /// The found or created mapping, or null if no mapping could be created. - public virtual INewInstanceMapping? FindOrBuildMapping( + public INewInstanceMapping? FindOrBuildMapping( ITypeSymbol sourceType, ITypeSymbol targetType, MappingBuildingOptions options = MappingBuildingOptions.Default ) { - sourceType = sourceType.UpgradeNullable(); - targetType = targetType.UpgradeNullable(); - return MappingBuilder.Find(sourceType, targetType) ?? BuildMapping(sourceType, targetType, options); + return FindOrBuildMapping(new TypeMappingKey(sourceType, targetType), options); } /// - /// Builds a new mapping for the provided types with the given options. + /// Tries to find an existing mapping for the provided mapping key. + /// If none is found, a new one is created. + /// If no mapping is possible for the provided types, + /// null is returned. + /// If a new mapping is created, it is added to the mapping descriptor + /// and returned in further calls to this method. + /// No user symbol is passed. /// - /// The source type. - /// The target type. + /// The mapping key. + /// The mapping building options. + /// The found or created mapping, or null if no mapping could be created. + public virtual INewInstanceMapping? FindOrBuildMapping( + TypeMappingKey mappingKey, + MappingBuildingOptions options = MappingBuildingOptions.Default + ) + { + return MappingBuilder.Find(mappingKey) ?? BuildMapping(mappingKey, options); + } + + /// + /// Finds or builds a mapping (). + /// Before a new mapping is built existing mappings are tried to be found by the following priorities: + /// 1. exact match + /// 2. ignoring the nullability of the source (needs to be handled by the caller of this method) + /// 3. ignoring the nullability of the target (needs to be handled by the caller of this method) + /// 4. ignoring the nullability of the source and the target (needs to be handled by the caller of this method) + /// If no mapping can be found a new mapping is built with the source and the target as non-nullables. + /// + /// The mapping key. + /// The options to build a new mapping if no existing mapping is found. + /// The found or built mapping, or null if none could be found and none could be built. + public INewInstanceMapping? FindOrBuildLooseNullableMapping( + TypeMappingKey key, + MappingBuildingOptions options = MappingBuildingOptions.Default + ) + { + return FindMapping(key) + ?? FindMapping(key.NonNullableSource()) + ?? FindMapping(key.NonNullableTarget()) + ?? FindOrBuildMapping(key.NonNullable(), options); + } + + /// + /// Builds a new mapping for the provided types and config with the given options. + /// + /// The mapping key. /// The options. /// The created mapping, or null if no mapping could be created. - public INewInstanceMapping? BuildMapping(ITypeSymbol source, ITypeSymbol target, MappingBuildingOptions options) + public INewInstanceMapping? BuildMapping(TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default) { var userSymbol = options.HasFlag(MappingBuildingOptions.KeepUserSymbol) ? UserSymbol : null; - return BuildMapping(userSymbol, source, target, options); + return BuildMapping(userSymbol, mappingKey, options); } /// @@ -120,39 +152,57 @@ bool clearDerivedTypes /// If no mapping is possible for the provided types, /// null is returned. /// If a new mapping is created, it is added to the mapping descriptor - /// and returned in further calls. + /// and returned in further calls to this method. /// No configuration / user symbol is passed. /// - /// The source type. - /// The target type. + /// The source type. + /// The target type. + /// The options. + /// The found or created mapping, or null if no mapping could be created. + public IExistingTargetMapping? FindOrBuildExistingTargetMapping( + ITypeSymbol source, + ITypeSymbol target, + MappingBuildingOptions options = MappingBuildingOptions.Default + ) => FindOrBuildExistingTargetMapping(new TypeMappingKey(source, target), options); + + /// + /// Tries to find an existing mapping which can work with an existing target object instance for the provided types. + /// If none is found, a new one is created. + /// If no mapping is possible for the provided types, + /// null is returned. + /// If a new mapping is created, it is added to the mapping descriptor + /// and returned in further calls to this method. + /// No configuration / user symbol is passed. + /// + /// The mapping key. /// The options. /// The found or created mapping, or null if no mapping could be created. public virtual IExistingTargetMapping? FindOrBuildExistingTargetMapping( - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default - ) => ExistingTargetMappingBuilder.Find(sourceType, targetType) ?? BuildExistingTargetMapping(sourceType, targetType, options); + ) + { + return ExistingTargetMappingBuilder.Find(mappingKey) ?? BuildExistingTargetMapping(mappingKey, options); + } /// /// Tries to build an existing target instance mapping. /// If no mapping is possible for the provided types, /// null is returned. /// If a new mapping is created, it is added to the mapping descriptor - /// and returned in further calls. + /// and returned in further calls to this method. /// No configuration / user symbol is passed. /// - /// The source type. - /// The target type. + /// The mapping key. /// The options. /// The created mapping, or null if no mapping could be created. public virtual IExistingTargetMapping? BuildExistingTargetMapping( - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options = MappingBuildingOptions.Default ) { var userSymbol = options.HasFlag(MappingBuildingOptions.KeepUserSymbol) ? UserSymbol : null; - var ctx = ContextForMapping(userSymbol, sourceType, targetType, options); + var ctx = ContextForMapping(userSymbol, mappingKey, options); return ExistingTargetMappingBuilder.Build(ctx, options.HasFlag(MappingBuildingOptions.MarkAsReusable)); } @@ -185,22 +235,16 @@ protected virtual NullFallbackValue GetNullFallbackValue(ITypeSymbol targetType, protected virtual MappingBuilderContext ContextForMapping( IMethodSymbol? userSymbol, - ITypeSymbol sourceType, - ITypeSymbol targetType, + TypeMappingKey mappingKey, MappingBuildingOptions options ) { - return new(this, userSymbol, sourceType, targetType, options.HasFlag(MappingBuildingOptions.ClearDerivedTypes)); + return new(this, userSymbol, mappingKey, options.HasFlag(MappingBuildingOptions.ClearDerivedTypes)); } - protected INewInstanceMapping? BuildMapping( - IMethodSymbol? userSymbol, - ITypeSymbol sourceType, - ITypeSymbol targetType, - MappingBuildingOptions options - ) + protected INewInstanceMapping? BuildMapping(IMethodSymbol? userSymbol, TypeMappingKey key, MappingBuildingOptions options) { - var ctx = ContextForMapping(userSymbol, sourceType.UpgradeNullable(), targetType.UpgradeNullable(), options); + var ctx = ContextForMapping(userSymbol, key, options); return MappingBuilder.Build(ctx, options.HasFlag(MappingBuildingOptions.MarkAsReusable)); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/ExistingTargetMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/ExistingTargetMappingBuilder.cs index 64a82a1681..3e5010dcf3 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/ExistingTargetMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/ExistingTargetMappingBuilder.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis; using Riok.Mapperly.Descriptors.Mappings.ExistingTarget; namespace Riok.Mapperly.Descriptors.MappingBuilders; @@ -24,8 +23,10 @@ public ExistingTargetMappingBuilder(MappingCollection mappings) _mappings = mappings; } - public IExistingTargetMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) => - _mappings.FindExistingInstanceMapping(sourceType, targetType); + public IExistingTargetMapping? Find(TypeMappingKey mappingKey) + { + return _mappings.FindExistingInstanceMapping(mappingKey); + } public IExistingTargetMapping? Build(MappingBuilderContext ctx, bool resultIsReusable) { @@ -36,7 +37,7 @@ public ExistingTargetMappingBuilder(MappingCollection mappings) if (resultIsReusable) { - _mappings.AddExistingTargetMapping(mapping); + _mappings.AddExistingTargetMapping(mapping, ctx.MappingKey.Configuration); } _mappings.EnqueueToBuildBody(mapping, ctx); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs index 791759253f..1f159c7107 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.UserMappings; @@ -42,8 +41,7 @@ public MappingBuilder(MappingCollection mappings) /// public IReadOnlyCollection UserMappings => _mappings.UserMappings; - /// - public INewInstanceMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) => _mappings.Find(sourceType, targetType); + public INewInstanceMapping? Find(TypeMappingKey mapping) => _mappings.Find(mapping); public INewInstanceMapping? Build(MappingBuilderContext ctx, bool resultIsReusable) { @@ -54,7 +52,7 @@ public MappingBuilder(MappingCollection mappings) if (resultIsReusable) { - _mappings.AddNewInstanceMapping(mapping); + _mappings.AddNewInstanceMapping(mapping, ctx.MappingKey.Configuration); } _mappings.EnqueueToBuildBody(mapping, ctx); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/NullableMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/NullableMappingBuilder.cs index 6ff07a1acd..2aa8108f9f 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/NullableMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/NullableMappingBuilder.cs @@ -8,28 +8,34 @@ public static class NullableMappingBuilder { public static NewInstanceMapping? TryBuildMapping(MappingBuilderContext ctx) { - var sourceIsNullable = ctx.Source.TryGetNonNullable(out var sourceNonNullable); - var targetIsNullable = ctx.Target.TryGetNonNullable(out var targetNonNullable); - if (!sourceIsNullable && !targetIsNullable) + if (!TryBuildNonNullableMappingKey(ctx, out var mappingKey)) return null; - var delegateMapping = ctx.BuildMapping( - sourceNonNullable ?? ctx.Source, - targetNonNullable ?? ctx.Target, - MappingBuildingOptions.KeepUserSymbol - ); + var delegateMapping = ctx.BuildMapping(mappingKey, MappingBuildingOptions.KeepUserSymbol); return delegateMapping == null ? null : BuildNullDelegateMapping(ctx, delegateMapping); } public static IExistingTargetMapping? TryBuildExistingTargetMapping(MappingBuilderContext ctx) + { + if (!TryBuildNonNullableMappingKey(ctx, out var mappingKey)) + return null; + + var delegateMapping = ctx.FindOrBuildExistingTargetMapping(mappingKey); + return delegateMapping == null ? null : new NullDelegateExistingTargetMapping(ctx.Source, ctx.Target, delegateMapping); + } + + private static bool TryBuildNonNullableMappingKey(MappingBuilderContext ctx, out TypeMappingKey mappingKey) { var sourceIsNullable = ctx.Source.TryGetNonNullable(out var sourceNonNullable); var targetIsNullable = ctx.Target.TryGetNonNullable(out var targetNonNullable); if (!sourceIsNullable && !targetIsNullable) - return null; + { + mappingKey = default; + return false; + } - var delegateMapping = ctx.FindOrBuildExistingTargetMapping(sourceNonNullable ?? ctx.Source, targetNonNullable ?? ctx.Target); - return delegateMapping == null ? null : new NullDelegateExistingTargetMapping(ctx.Source, ctx.Target, delegateMapping); + mappingKey = new TypeMappingKey(sourceNonNullable ?? ctx.Source, targetNonNullable ?? ctx.Target); + return true; } private static NewInstanceMapping BuildNullDelegateMapping(MappingBuilderContext ctx, INewInstanceMapping mapping) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/QueryableMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/QueryableMappingBuilder.cs index fd5d145825..794def4b85 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/QueryableMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/QueryableMappingBuilder.cs @@ -20,9 +20,10 @@ public static class QueryableMappingBuilder var sourceType = sourceQueryable.TypeArguments[0]; var targetType = targetQueryable.TypeArguments[0]; + var mappingKey = new TypeMappingKey(sourceType, targetType); - var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, sourceType, targetType); - var mapping = inlineCtx.BuildMapping(sourceType, targetType, MappingBuildingOptions.KeepUserSymbol); + var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, mappingKey); + var mapping = inlineCtx.BuildMapping(mappingKey, MappingBuildingOptions.KeepUserSymbol); if (mapping == null) return null; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs index 1b1fc88a45..cd8afa0b4a 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs @@ -1,6 +1,8 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; using Riok.Mapperly.Descriptors.Mappings; +using Riok.Mapperly.Diagnostics; +using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilders; @@ -11,8 +13,18 @@ public static class ToStringMappingBuilder if (!ctx.IsConversionEnabled(MappingConversionType.ToStringMethod)) return null; - return ctx.Target.SpecialType == SpecialType.System_String - ? new SourceObjectMethodMapping(ctx.Source, ctx.Target, nameof(ToString)) - : null; + if (ctx.Target.SpecialType != SpecialType.System_String) + return null; + + if (ctx.MappingKey.Configuration.StringFormat == null) + return new ToStringMapping(ctx.Source, ctx.Target); + + if (!ctx.Source.Implements(ctx.Types.Get())) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.SourceDoesNotImplementIFormattable, ctx.Source); + return new ToStringMapping(ctx.Source, ctx.Target); + } + + return new ToStringMapping(ctx.Source, ctx.Target, ctx.MappingKey.Configuration.StringFormat); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index fe75557907..e089eb9ff3 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.ExistingTarget; using Riok.Mapperly.Descriptors.Mappings.UserMappings; @@ -9,7 +8,7 @@ namespace Riok.Mapperly.Descriptors; public class MappingCollection { /// - /// The first callable mapping of each type pair. + /// The first callable mapping of each type pair + config. /// Contains mappings to build and already built mappings /// private readonly Dictionary _mappings = new(); @@ -39,22 +38,22 @@ public class MappingCollection /// public IReadOnlyCollection UserMappings => _userMappings; - public INewInstanceMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) + public INewInstanceMapping? Find(TypeMappingKey mappingKey) { - _mappings.TryGetValue(new TypeMappingKey(sourceType, targetType), out var mapping); + _mappings.TryGetValue(mappingKey, out var mapping); return mapping; } - public IExistingTargetMapping? FindExistingInstanceMapping(ITypeSymbol sourceType, ITypeSymbol targetType) + public IExistingTargetMapping? FindExistingInstanceMapping(TypeMappingKey mappingKey) { - _existingTargetMappings.TryGetValue(new TypeMappingKey(sourceType, targetType), out var mapping); + _existingTargetMappings.TryGetValue(mappingKey, out var mapping); return mapping; } public void EnqueueToBuildBody(IMapping mapping, MappingBuilderContext ctx) => _mappingsToBuildBody.Enqueue((mapping, ctx), mapping.BodyBuildingPriority); - public void Add(ITypeMapping mapping) + public void Add(ITypeMapping mapping, TypeMappingConfiguration config) { if (mapping is IUserMapping userMapping) { @@ -64,34 +63,36 @@ public void Add(ITypeMapping mapping) switch (mapping) { case INewInstanceMapping newInstanceMapping: - AddNewInstanceMapping(newInstanceMapping); + AddNewInstanceMapping(newInstanceMapping, config); break; case IExistingTargetMapping existingTargetMapping: - AddExistingTargetMapping(existingTargetMapping); + AddExistingTargetMapping(existingTargetMapping, config); break; default: throw new ArgumentOutOfRangeException(nameof(mapping), mapping.GetType().FullName + " mappings are not supported"); } } - public void AddNewInstanceMapping(INewInstanceMapping mapping) + public void AddNewInstanceMapping(INewInstanceMapping mapping, TypeMappingConfiguration config) { if (mapping is MethodMapping methodMapping) { _methodMappings.Add(methodMapping); } - if (mapping.CallableByOtherMappings && Find(mapping.SourceType, mapping.TargetType) is null) + var mappingKey = new TypeMappingKey(mapping, config); + if (mapping.CallableByOtherMappings && Find(mappingKey) is null) { - _mappings.Add(new TypeMappingKey(mapping), mapping); + _mappings.Add(mappingKey, mapping); } } - public void AddExistingTargetMapping(IExistingTargetMapping mapping) + public void AddExistingTargetMapping(IExistingTargetMapping mapping, TypeMappingConfiguration config) { - if (mapping.CallableByOtherMappings && FindExistingInstanceMapping(mapping.SourceType, mapping.TargetType) is null) + var mappingKey = new TypeMappingKey(mapping, config); + if (mapping.CallableByOtherMappings && FindExistingInstanceMapping(mappingKey) is null) { - _existingTargetMappings.Add(new TypeMappingKey(mapping), mapping); + _existingTargetMappings.Add(mappingKey, mapping); } } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs index bdc4d1bb9d..fcd2cfe176 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs @@ -1,6 +1,5 @@ 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; @@ -27,12 +26,14 @@ public SourceObjectMethodMapping( : base(sourceType, targetType) { _methodName = methodName; - this._delegateMapping = delegateMapping; + _delegateMapping = delegateMapping; } public override ExpressionSyntax Build(TypeMappingBuildContext ctx) { - var sourceExpression = InvocationExpression(MemberAccess(ctx.Source, _methodName)); + var sourceExpression = Invocation(MemberAccess(ctx.Source, _methodName), BuildArguments(ctx).ToArray()); return _delegateMapping == null ? sourceExpression : _delegateMapping.Build(ctx.WithSource(sourceExpression)); } + + protected virtual IEnumerable BuildArguments(TypeMappingBuildContext ctx) => Enumerable.Empty(); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs new file mode 100644 index 0000000000..1d14502562 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs @@ -0,0 +1,32 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings; + +/// +/// Represents a mapping which works by invoking +/// the instance method on the source object. +/// +/// target = source.ToString(); +/// +/// +public class ToStringMapping : SourceObjectMethodMapping +{ + private readonly string? _stringFormat; + + public ToStringMapping(ITypeSymbol sourceType, ITypeSymbol targetType, string? stringFormat = null) + : base(sourceType, targetType, nameof(ToString)) + { + _stringFormat = stringFormat; + } + + protected override IEnumerable BuildArguments(TypeMappingBuildContext ctx) + { + if (_stringFormat == null) + yield break; + + yield return StringLiteral(_stringFormat); + yield return NullLiteral(); + } +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs b/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs new file mode 100644 index 0000000000..5aaeced986 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs @@ -0,0 +1,11 @@ +namespace Riok.Mapperly.Descriptors; + +/// +/// Configuration for a type mapping. +/// Eg. the format to apply to `ToString` calls. +/// +/// The format to apply to `ToString` calls. +public record TypeMappingConfiguration(string? StringFormat = null) +{ + public static readonly TypeMappingConfiguration Default = new(); +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs b/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs index 7aea2396f4..c46b13f0bd 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs @@ -1,25 +1,39 @@ +using System.Diagnostics; using Microsoft.CodeAnalysis; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors; +[DebuggerDisplay("{Source.Name} => {Target.Name}")] public readonly struct TypeMappingKey { private static readonly IEqualityComparer _comparer = SymbolEqualityComparer.IncludeNullability; - private readonly ITypeSymbol _source; - private readonly ITypeSymbol _target; - public TypeMappingKey(ITypeMapping mapping, bool includeNullability = true) - : this(mapping.SourceType, mapping.TargetType, includeNullability) { } + public TypeMappingKey(ITypeMapping mapping, TypeMappingConfiguration? config = null, bool includeNullability = true) + : this(mapping.SourceType, mapping.TargetType, config, includeNullability) { } - public TypeMappingKey(ITypeSymbol source, ITypeSymbol target, bool includeNullability = true) + public TypeMappingKey(ITypeSymbol source, ITypeSymbol target, TypeMappingConfiguration? config = null, bool includeNullability = true) { - _source = includeNullability ? source : source.NonNullable(); - _target = includeNullability ? target : target.NonNullable(); + Configuration = config ?? TypeMappingConfiguration.Default; + Source = includeNullability ? source.UpgradeNullable() : source.NonNullable(); + Target = includeNullability ? target.UpgradeNullable() : target.NonNullable(); } - private bool Equals(TypeMappingKey other) => _comparer.Equals(_source, other._source) && _comparer.Equals(_target, other._target); + public ITypeSymbol Source { get; } + + public ITypeSymbol Target { get; } + + public TypeMappingConfiguration Configuration { get; } + + public TypeMappingKey NonNullableSource() => new(Source.NonNullable(), Target, Configuration); + + public TypeMappingKey NonNullableTarget() => new(Source, Target.NonNullable(), Configuration); + + public TypeMappingKey NonNullable() => new(Source.NonNullable(), Target.NonNullable(), Configuration); + + private bool Equals(TypeMappingKey other) => + _comparer.Equals(Source, other.Source) && _comparer.Equals(Target, other.Target) && Configuration.Equals(other.Configuration); public override bool Equals(object? obj) => obj is TypeMappingKey other && Equals(other); @@ -27,8 +41,9 @@ public override int GetHashCode() { unchecked { - var hashCode = _comparer.GetHashCode(_source); - hashCode = (hashCode * 397) ^ _comparer.GetHashCode(_target); + var hashCode = _comparer.GetHashCode(Source); + hashCode = (hashCode * 397) ^ _comparer.GetHashCode(Target); + hashCode = (hashCode * 397) ^ EqualityComparer.Default.GetHashCode(Configuration); return hashCode; } } diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 4dc7399376..02e01b609d 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -473,4 +473,13 @@ public static class DiagnosticDescriptors DiagnosticSeverity.Error, true ); + + public static readonly DiagnosticDescriptor SourceDoesNotImplementIFormattable = new DiagnosticDescriptor( + "RMG055", + $"The source type does not implement {nameof(IFormattable)}, string format cannot be applied", + $"The source type {{0}} does not implement {nameof(IFormattable)}, string format cannot be applied", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); } diff --git a/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Literal.cs b/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Literal.cs index 22ed0af275..6e748d8518 100644 --- a/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Literal.cs +++ b/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Literal.cs @@ -15,6 +15,6 @@ public static LiteralExpressionSyntax BooleanLiteral(bool b) => public static LiteralExpressionSyntax IntLiteral(int i) => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(i)); - private static LiteralExpressionSyntax StringLiteral(string content) => + public static LiteralExpressionSyntax StringLiteral(string content) => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(content)); } diff --git a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs index 8e6e21cf2c..83bb255d02 100644 --- a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs +++ b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs @@ -55,6 +55,12 @@ internal static bool TryGetEnumUnderlyingType(this ITypeSymbol t, [NotNullWhen(t return namedType.GetMembers(methodName).OfType().FirstOrDefault(m => m.IsStatic && m.IsGenericMethod); } + internal static bool Implements(this ITypeSymbol t, INamedTypeSymbol interfaceSymbol) + { + return SymbolEqualityComparer.Default.Equals(t, interfaceSymbol) + || t.AllInterfaces.Any(x => SymbolEqualityComparer.Default.Equals(x, interfaceSymbol)); + } + internal static bool ImplementsGeneric( this ITypeSymbol t, INamedTypeSymbol genericInterfaceSymbol, diff --git a/src/Riok.Mapperly/Symbols/MemberPath.cs b/src/Riok.Mapperly/Symbols/MemberPath.cs index 12a5f755db..303e4e2753 100644 --- a/src/Riok.Mapperly/Symbols/MemberPath.cs +++ b/src/Riok.Mapperly/Symbols/MemberPath.cs @@ -33,7 +33,7 @@ public MemberPath(IReadOnlyList path) /// /// Gets the last part of the path or throws if there is none. /// - public IMappableMember Member => Path.Last(); + public IMappableMember Member => Path[^1]; /// /// Gets the type of the . If any part of the path is nullable, this type will be nullable too. diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs index 0490dc9fca..42bc405b2a 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs @@ -569,4 +569,26 @@ static partial class Mapper """ ); } + + [Fact] + public void ClassConstructorParameterWithStringFormat() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value", "value", StringFormat = "C")] + partial B Map(A source);", + """, + "class A { public int Value { get; set; } }", + "class B { public B(string value) {} }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(source.Value.ToString("C", null)); + return target; + """ + ); + } } diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs index ff9d271d5b..c81708c312 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs @@ -394,4 +394,29 @@ public Task RequiredPropertySourceNotFoundShouldDiagnostic() return TestHelper.VerifyGenerator(source); } + + [Fact] + public void ClassInitOnlyPropertyWithStringFormat() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value", "Value", StringFormat = "C")] + partial B Map(A source);", + """, + "class A { public int Value { get; set; } }", + "class B { public string Value { get; init; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B() + { + Value = source.Value.ToString("C", null), + }; + return target; + """ + ); + } } diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs index 781b7d9514..40d0ac46f6 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs @@ -66,6 +66,23 @@ public Task ClassToClassWithConfigs() return TestHelper.VerifyGenerator(source); } + [Fact] + public Task QueryablePropertyWithStringFormat() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial System.Linq.IQueryable Map(System.Linq.IQueryable source); + + [MapProperty("Value", "Value", StringFormat = "C")] + private partial B MapPrivate(A source);", + """, + "class A { public int Value { get; set; } }", + "class B { public string Value { get; init; } }" + ); + + return TestHelper.VerifyGenerator(source); + } + [Fact] public Task RecordToRecordManualFlatteningInsideList() { diff --git a/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs b/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs new file mode 100644 index 0000000000..fe791fba1f --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs @@ -0,0 +1,84 @@ +using Riok.Mapperly.Diagnostics; + +namespace Riok.Mapperly.Tests.Mapping; + +public class ToStringFormattedTest +{ + [Fact] + public void ClassMultiplePropertiesToStringWithDifferentFormats() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value1", "Value1", StringFormat = "dd.MM.yyyy")] + [MapProperty("Value2", "Value2", StringFormat = "yyyy-MM-dd")] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } public DateTime Value1 { get; set; } public DateTime Value2 { get; set; } }", + "class B { public string Value { get; set; } public string Value1 { get; set; } public string Value2 { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(); + target.Value1 = source.Value1.ToString("dd.MM.yyyy", null); + target.Value2 = source.Value2.ToString("yyyy-MM-dd", null); + return target; + """ + ); + } + + [Fact] + public void RecordMultiplePropertiesToStringWithDifferentFormats() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value1", "Value1", StringFormat = "dd.MM.yyyy")] + [MapProperty("Value2", "Value2", StringFormat = "yyyy-MM-dd")] + partial B Map(A source);", + """, + "record A(DateTime Value, DateTime Value1, DateTime Value2);", + "record B(string Value, string Value1, string Value2);" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(source.Value.ToString(), source.Value1.ToString("dd.MM.yyyy", null), source.Value2.ToString("yyyy-MM-dd", null)); + return target; + """ + ); + } + + [Fact] + public void ClassToStringWithoutFormatParameterShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value", "Value", StringFormat = "C")] + partial B Map(A source);", + """, + "class A { public C Value { get; set; } }", + "class B { public string Value { get; set; } }", + "class C {}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.SourceDoesNotImplementIFormattable, + "The source type C does not implement IFormattable, string format cannot be applied" + ) + .HaveAssertedAllDiagnostics() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(); + return target; + """ + ); + } +} diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.QueryablePropertyWithStringFormat#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.QueryablePropertyWithStringFormat#Mapper.g.verified.cs new file mode 100644 index 0000000000..24ad978f8c --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.QueryablePropertyWithStringFormat#Mapper.g.verified.cs @@ -0,0 +1,24 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source) + { +#nullable disable + return System.Linq.Queryable.Select(source, x => new global::B() + { + Value = x.Value.ToString("C", null), + }); +#nullable enable + } + + private partial global::B MapPrivate(global::A source) + { + var target = new global::B() + { + Value = source.Value.ToString("C", null), + }; + return target; + } +} \ No newline at end of file