From b17f666eff839283e5d8976e52f11df307939840 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 May 2023 09:28:56 +0200 Subject: [PATCH] feat: accept destination type as mapping method parameter (#398) --- .../03-generated-mapper-example.mdx | 2 +- docs/docs/02-configuration/01-mapper.md | 4 +- docs/docs/02-configuration/04-enum.mdx | 2 +- .../10-derived-type-mapping.md | 28 +-- .../11-runtime-target-type-mapping.md | 39 +++ ...e-handling.md => 12-reference-handling.md} | 0 ...tions.mdx => 13-queryable-projections.mdx} | 0 .../{13-conversions.md => 14-conversions.md} | 0 ...ostics.mdx => 15-analyzer-diagnostics.mdx} | 0 ...ted-source.mdx => 16-generated-source.mdx} | 0 .../MappingBodyBuilders/MappingBodyBuilder.cs | 3 + .../RuntimeTargetTypeMappingBodyBuilder.cs | 40 ++++ .../Descriptors/MappingBuilderContext.cs | 3 + .../DerivedTypeMappingBuilder.cs | 31 ++- .../MappingBuilders/MappingBuilder.cs | 3 + .../Descriptors/MappingCollection.cs | 68 ++---- .../Mappings/DerivedTypeSwitchMapping.cs | 6 +- .../UserDefinedExistingTargetMethodMapping.cs | 9 +- .../MemberMappings/MemberAssignmentMapping.cs | 7 +- .../Descriptors/Mappings/MethodMapping.cs | 43 ++-- .../Descriptors/Mappings/TypeMapping.cs | 2 +- .../UserDefinedNewInstanceMethodMapping.cs | 7 +- ...inedNewInstanceRuntimeTargetTypeMapping.cs | 113 +++++++++ .../Descriptors/TypeMappingKey.cs | 43 ++++ .../Descriptors/UserMethodMappingExtractor.cs | 76 +++++- .../Descriptors/WellKnownTypes.cs | 4 +- src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs | 3 + .../Helpers/NullableSymbolExtensions.cs | 14 +- src/Riok.Mapperly/Helpers/SymbolExtensions.cs | 14 +- .../Symbols/MappingMethodParameters.cs | 16 +- src/Riok.Mapperly/Symbols/MethodArgument.cs | 13 +- src/Riok.Mapperly/Symbols/MethodParameter.cs | 15 +- ...untimeTargetTypeMappingMethodParameters.cs | 12 + .../Mapper/StaticTestMapper.cs | 6 + .../StaticMapperTest.cs | 12 + ...erTest.SnapshotGeneratedSource.verified.cs | 44 ++++ ...erTest.SnapshotGeneratedSource.verified.cs | 44 ++++ ...erTest.SnapshotGeneratedSource.verified.cs | 44 ++++ .../Mapping/ReferenceHandlingTest.cs | 34 +++ .../Mapping/RuntimeTargetTypeMappingTest.cs | 222 ++++++++++++++++++ .../Mapping/UserMethodTest.cs | 48 +++- ...eTargetTypeShouldWork#Mapper.g.verified.cs | 23 ++ ...nceHandlingShouldWork#Mapper.g.verified.cs | 22 ++ ...houldIncludeNullables#Mapper.g.verified.cs | 43 ++++ 44 files changed, 1004 insertions(+), 158 deletions(-) create mode 100644 docs/docs/02-configuration/11-runtime-target-type-mapping.md rename docs/docs/02-configuration/{11-reference-handling.md => 12-reference-handling.md} (100%) rename docs/docs/02-configuration/{12-queryable-projections.mdx => 13-queryable-projections.mdx} (100%) rename docs/docs/02-configuration/{13-conversions.md => 14-conversions.md} (100%) rename docs/docs/02-configuration/{14-analyzer-diagnostics.mdx => 15-analyzer-diagnostics.mdx} (100%) rename docs/docs/02-configuration/{15-generated-source.mdx => 16-generated-source.mdx} (100%) create mode 100644 src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs create mode 100644 src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceRuntimeTargetTypeMapping.cs create mode 100644 src/Riok.Mapperly/Descriptors/TypeMappingKey.cs create mode 100644 src/Riok.Mapperly/Symbols/RuntimeTargetTypeMappingMethodParameters.cs create mode 100644 test/Riok.Mapperly.Tests/Mapping/RuntimeTargetTypeMappingTest.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeShouldWork#Mapper.g.verified.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeWithReferenceHandlingShouldWork#Mapper.g.verified.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/RuntimeTargetTypeMappingTest.WithNullableObjectSourceAndTargetTypeShouldIncludeNullables#Mapper.g.verified.cs diff --git a/docs/docs/01-getting-started/03-generated-mapper-example.mdx b/docs/docs/01-getting-started/03-generated-mapper-example.mdx index 5e63799335..393ce886cb 100644 --- a/docs/docs/01-getting-started/03-generated-mapper-example.mdx +++ b/docs/docs/01-getting-started/03-generated-mapper-example.mdx @@ -8,7 +8,7 @@ import GeneratedCarMapperSource from '!!raw-loader!../../src/data/generated/samp This example will show you what kind of code Mapperly generates. It is based on the [Mapperly sample](https://github.com/riok/mapperly/tree/main/samples/Riok.Mapperly.Sample). -To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/15-generated-source.mdx). +To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/16-generated-source.mdx). ## The source classes diff --git a/docs/docs/02-configuration/01-mapper.md b/docs/docs/02-configuration/01-mapper.md index 2c1e23b662..92290a0021 100644 --- a/docs/docs/02-configuration/01-mapper.md +++ b/docs/docs/02-configuration/01-mapper.md @@ -84,7 +84,7 @@ To enforce strict mappings (all source members have to be mapped to a target member and all target members have to be mapped from a source member, except for ignored members) -set the following two EditorConfig settings (see also [analyzer diagnostics](./14-analyzer-diagnostics.mdx)): +set the following two EditorConfig settings (see also [analyzer diagnostics](./15-analyzer-diagnostics.mdx)): ```editorconfig title=".editorconfig" [*.cs] @@ -94,4 +94,4 @@ 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](./04-enum.mdx). +To enforce strict enum mappings set `RMG037` and `RMG038` to error, see [strict enum mappings](./04-enum.mdx). diff --git a/docs/docs/02-configuration/04-enum.mdx b/docs/docs/02-configuration/04-enum.mdx index f70c493e42..b56f3a911c 100644 --- a/docs/docs/02-configuration/04-enum.mdx +++ b/docs/docs/02-configuration/04-enum.mdx @@ -57,7 +57,7 @@ public partial class CarMapper To enforce strict enum mappings (all source enum values have to be mapped to a target enum value and all target enum values have to be mapped from a source enum value) -set the following two EditorConfig settings (see also [analyzer diagnostics](./14-analyzer-diagnostics.mdx)): +set the following two EditorConfig settings (see also [analyzer diagnostics](./15-analyzer-diagnostics.mdx)): ```editorconfig title=".editorconfig" [*.cs] diff --git a/docs/docs/02-configuration/10-derived-type-mapping.md b/docs/docs/02-configuration/10-derived-type-mapping.md index 6f447bb5e6..1ef49cd762 100644 --- a/docs/docs/02-configuration/10-derived-type-mapping.md +++ b/docs/docs/02-configuration/10-derived-type-mapping.md @@ -17,19 +17,19 @@ This can be configured with the `MapDerivedTypeAttribute`: public static partial class ModelMapper { // highlight-start - [MapDerivedType] // for c# language level ≥ 11 - [MapDerivedType(typeof(Porsche), typeof(PorscheDto))] // for c# language level < 11 + [MapDerivedType] // for c# language level ≥ 11 + [MapDerivedType(typeof(Apple), typeof(AppleDto))] // for c# language level < 11 // highlight-end - public static partial CarDto MapCar(Car source); + public static partial FruitDto MapFruit(Fruit source); } -abstract class Car {} -class Audi : Car {} -class Porsche : Car {} +abstract class Fruit {} +class Banana : Fruit {} +class Apple : Fruit {} -abstract class CarDto {} -class AudiDto : CarDto {} -class PorscheDto : CarDto {} +abstract class FruitDto {} +class BananaDto : FruitDto {} +class AppleDto : FruitDto {} ``` @@ -39,17 +39,17 @@ class PorscheDto : CarDto {} [Mapper] public static partial class ModelMapper { - public static partial CarDto MapCar(Car source) + public static partial FruitDto MapFruit(Fruit source) { return source switch { - Audi x => MapToAudiDto(x), - Porsche x => MapToPorscheDto(x), - _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to CarDto as there is no known derived type mapping", nameof(source)), + Banana x => MapToBananaDto(x), + Apple x => MapToAppleDto(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to FruitDto as there is no known derived type mapping", nameof(source)), }; } - // ... implementations of MapToAudiDto and MapToPorscheDto + // ... implementations of MapToBananaDto and MapToAppleDto } ``` diff --git a/docs/docs/02-configuration/11-runtime-target-type-mapping.md b/docs/docs/02-configuration/11-runtime-target-type-mapping.md new file mode 100644 index 0000000000..d1638e92d9 --- /dev/null +++ b/docs/docs/02-configuration/11-runtime-target-type-mapping.md @@ -0,0 +1,39 @@ +# Runtime target type mapping + +If the target type of a mapping is not known at compile time, +a mapping method with a `Type` parameter can be used. +Mapperly implements this mapping method +using all mappings the user defined in the mapper. + +```csharp +[Mapper] +public static partial class ModelMapper +{ + // highlight-start + public static partial object Map(object source, Type targetType); + // highlight-end + + private static partial BananaDto MapBanana(Banana source); + private static partial AppleDto MapApple(Apple source); +} + +class Banana {} +class Apple {} + +class BananaDto {} +class AppleDto {} +``` + +If the source or target type of a runtime target type mapping is not `object`, +only user mappings of which the source/target type is assignable to the source/target type of the mapping method are considered. + +Runtime target type mappings support [derived type mappings](./10-derived-type-mapping.md). +The `MapDerivedTypeAttribute` can be directly applied to a runtime target type mapping method. + +:::info +Mapperly runtime target type mappings +only support source/target type combinations which are defined +as mappings in the same mapper. +If an unknown source/target type combination is provided at runtime, +an `ArgumentException` is thrown. +::: diff --git a/docs/docs/02-configuration/11-reference-handling.md b/docs/docs/02-configuration/12-reference-handling.md similarity index 100% rename from docs/docs/02-configuration/11-reference-handling.md rename to docs/docs/02-configuration/12-reference-handling.md diff --git a/docs/docs/02-configuration/12-queryable-projections.mdx b/docs/docs/02-configuration/13-queryable-projections.mdx similarity index 100% rename from docs/docs/02-configuration/12-queryable-projections.mdx rename to docs/docs/02-configuration/13-queryable-projections.mdx diff --git a/docs/docs/02-configuration/13-conversions.md b/docs/docs/02-configuration/14-conversions.md similarity index 100% rename from docs/docs/02-configuration/13-conversions.md rename to docs/docs/02-configuration/14-conversions.md diff --git a/docs/docs/02-configuration/14-analyzer-diagnostics.mdx b/docs/docs/02-configuration/15-analyzer-diagnostics.mdx similarity index 100% rename from docs/docs/02-configuration/14-analyzer-diagnostics.mdx rename to docs/docs/02-configuration/15-analyzer-diagnostics.mdx diff --git a/docs/docs/02-configuration/15-generated-source.mdx b/docs/docs/02-configuration/16-generated-source.mdx similarity index 100% rename from docs/docs/02-configuration/15-generated-source.mdx rename to docs/docs/02-configuration/16-generated-source.mdx diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MappingBodyBuilder.cs index 8a8a50c144..25469e1979 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MappingBodyBuilder.cs @@ -37,6 +37,9 @@ public void BuildMappingBodies() case UserDefinedExistingTargetMethodMapping mapping: UserMethodMappingBodyBuilder.BuildMappingBody(ctx, mapping); break; + case UserDefinedNewInstanceRuntimeTargetTypeMapping mapping: + RuntimeTargetTypeMappingBodyBuilder.BuildMappingBody(ctx, mapping); + break; } } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs new file mode 100644 index 0000000000..5e468cb188 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/RuntimeTargetTypeMappingBodyBuilder.cs @@ -0,0 +1,40 @@ +using Riok.Mapperly.Descriptors.MappingBuilders; +using Riok.Mapperly.Descriptors.Mappings; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Descriptors.MappingBodyBuilders; + +public static class RuntimeTargetTypeMappingBodyBuilder +{ + public static void BuildMappingBody(MappingBuilderContext ctx, UserDefinedNewInstanceRuntimeTargetTypeMapping mapping) + { + // source nulls are filtered out by the type switch arms, + // therefore set source type always to nun-nullable + // as non-nullables are also assignable to nullables. + IEnumerable mappings = ctx.CallableUserMappings.Where( + x => + x.SourceType.NonNullable().IsAssignableTo(ctx.Compilation, mapping.SourceType) + && x.TargetType.IsAssignableTo(ctx.Compilation, mapping.TargetType) + ); + + // include derived type mappings declared on this user defined method + var derivedTypeMappings = DerivedTypeMappingBuilder.TryBuildContainedMappings(ctx, true); + if (derivedTypeMappings != null) + { + mappings = derivedTypeMappings.Concat(mappings); + } + + // prefer non-nullable return types + // and prefer types with a higher inheritance level + // over types with a lower inheritance level + // in the type switch + // to use the most specific mapping + mappings = mappings + .OrderByDescending(x => x.SourceType.GetInheritanceLevel()) + .ThenByDescending(x => x.TargetType.GetInheritanceLevel()) + .ThenBy(x => x.TargetType.IsNullable()) + .GroupBy(x => new TypeMappingKey(x, false)) + .Select(x => x.First()); + mapping.AddMappings(mappings); + } +} diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 24e82392b0..2baf377e86 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -42,6 +42,9 @@ protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSy public ObjectFactoryCollection ObjectFactories { get; } + /// + public IReadOnlyCollection CallableUserMappings => MappingBuilder.CallableUserMappings; + public T GetConfigurationOrDefault() where T : Attribute => Configuration.GetOrDefault(_userSymbol); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/DerivedTypeMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/DerivedTypeMappingBuilder.cs index 210f926abf..a1c7c2b54a 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/DerivedTypeMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/DerivedTypeMappingBuilder.cs @@ -11,21 +11,30 @@ public static class DerivedTypeMappingBuilder { public static ITypeMapping? TryBuildMapping(MappingBuilderContext ctx) { - var configs = ctx.ListConfiguration() - .Concat(ctx.ListConfiguration, MapDerivedType>()) - .ToList(); - if (configs.Count == 0) + var derivedTypeMappings = TryBuildContainedMappings(ctx); + if (derivedTypeMappings == null) return null; - var derivedTypeMappings = BuildDerivedTypeMappings(ctx, configs); return ctx.IsExpression ? new DerivedTypeIfExpressionMapping(ctx.Source, ctx.Target, derivedTypeMappings) : new DerivedTypeSwitchMapping(ctx.Source, ctx.Target, derivedTypeMappings); } - private static IReadOnlyCollection BuildDerivedTypeMappings( + public static IReadOnlyCollection? TryBuildContainedMappings( + MappingBuilderContext ctx, + bool duplicatedSourceTypesAllowed = false + ) + { + var configs = ctx.ListConfiguration() + .Concat(ctx.ListConfiguration, MapDerivedType>()) + .ToList(); + return configs.Count == 0 ? null : BuildContainedMappings(ctx, configs, duplicatedSourceTypesAllowed); + } + + private static IReadOnlyCollection BuildContainedMappings( MappingBuilderContext ctx, - IReadOnlyCollection configs + IReadOnlyCollection configs, + bool duplicatedSourceTypesAllowed ) { var derivedTypeMappingSourceTypes = new HashSet(SymbolEqualityComparer.Default); @@ -33,9 +42,9 @@ IReadOnlyCollection configs foreach (var config in configs) { - // set reference types non-nullable as they can never be null when type-switching. - var sourceType = config.SourceType.WithNullableAnnotation(NullableAnnotation.NotAnnotated); - if (!derivedTypeMappingSourceTypes.Add(sourceType)) + // set types non-nullable as they can never be null when type-switching. + var sourceType = config.SourceType.NonNullable(); + if (!duplicatedSourceTypesAllowed && !derivedTypeMappingSourceTypes.Add(sourceType)) { ctx.ReportDiagnostic(DiagnosticDescriptors.DerivedSourceTypeDuplicated, sourceType); continue; @@ -47,7 +56,7 @@ IReadOnlyCollection configs continue; } - var targetType = config.TargetType.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + var targetType = config.TargetType.NonNullable(); if (!targetType.IsAssignableTo(ctx.Compilation, ctx.Target)) { ctx.ReportDiagnostic(DiagnosticDescriptors.DerivedTargetTypeIsNotAssignableToReturnType, targetType, ctx.Target); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs index 6dcc2e7131..1c0dd697e6 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs @@ -36,6 +36,9 @@ public MappingBuilder(MappingCollection mappings) _mappings = mappings; } + /// + public IReadOnlyCollection CallableUserMappings => _mappings.CallableUserMappings; + /// public ITypeMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) => _mappings.Find(sourceType, targetType); diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index ae49865ac2..68fca8b090 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -7,20 +7,37 @@ namespace Riok.Mapperly.Descriptors; public class MappingCollection { - // this includes mappings to build and already built mappings + /// + /// The first callable mapping of each type pair. + /// Contains mappings to build and already built mappings + /// private readonly Dictionary _mappings = new(); - // a list of all method mappings (extra mappings and mappings) + /// + /// A list of all method mappings (extra mappings and mappings) + /// private readonly List _methodMappings = new(); - // queue of mappings which don't have the body built yet + /// + /// A list of all callable user mappings with true. + /// + private readonly List _callableUserMappings = new(); + + /// + /// Queue of mappings which don't have the body built yet + /// private readonly Queue<(IMapping, MappingBuilderContext)> _mappingsToBuildBody = new(); - // a list of existing target mappings + /// + /// All existing target mappings + /// private readonly Dictionary _existingTargetMappings = new(); public IReadOnlyCollection MethodMappings => _methodMappings; + /// + public IReadOnlyCollection CallableUserMappings => _callableUserMappings; + public ITypeMapping? Find(ITypeSymbol sourceType, ITypeSymbol targetType) { _mappings.TryGetValue(new TypeMappingKey(sourceType, targetType), out var mapping); @@ -37,6 +54,11 @@ public class MappingCollection public void Add(ITypeMapping mapping) { + if (mapping is IUserMapping { CallableByOtherMappings: true } userMapping) + { + _callableUserMappings.Add(userMapping); + } + if (mapping is MethodMapping methodMapping) { _methodMappings.Add(methodMapping); @@ -52,42 +74,4 @@ public void AddExistingTargetMapping(IExistingTargetMapping mapping) => _existingTargetMappings.Add(new TypeMappingKey(mapping), mapping); public IEnumerable<(IMapping, MappingBuilderContext)> DequeueMappingsToBuildBody() => _mappingsToBuildBody.DequeueAll(); - - private readonly struct TypeMappingKey - { - private static readonly IEqualityComparer _comparer = SymbolEqualityComparer.IncludeNullability; - - private readonly ITypeSymbol _source; - private readonly ITypeSymbol _target; - - public TypeMappingKey(ITypeMapping mapping) - : this(mapping.SourceType, mapping.TargetType) { } - - public TypeMappingKey(IExistingTargetMapping mapping) - : this(mapping.SourceType, mapping.TargetType) { } - - public TypeMappingKey(ITypeSymbol source, ITypeSymbol target) - { - _source = source; - _target = target; - } - - private bool Equals(TypeMappingKey other) => _comparer.Equals(_source, other._source) && _comparer.Equals(_target, other._target); - - public override bool Equals(object? obj) => obj is TypeMappingKey other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - var hashCode = _comparer.GetHashCode(_source); - hashCode = (hashCode * 397) ^ _comparer.GetHashCode(_target); - return hashCode; - } - } - - public static bool operator ==(TypeMappingKey left, TypeMappingKey right) => left.Equals(right); - - public static bool operator !=(TypeMappingKey left, TypeMappingKey right) => !left.Equals(right); - } } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/DerivedTypeSwitchMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/DerivedTypeSwitchMapping.cs index a6529aa596..0cd033495f 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/DerivedTypeSwitchMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/DerivedTypeSwitchMapping.cs @@ -11,7 +11,7 @@ namespace Riok.Mapperly.Descriptors.Mappings; /// public class DerivedTypeSwitchMapping : TypeMapping { - private const string GetTypeMethodName = "GetType"; + private const string GetTypeMethodName = nameof(GetType); private readonly IReadOnlyCollection _typeMappings; @@ -33,7 +33,7 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx) ) ); - // source switch { A x => MapToA(x), B x => MapToB(x) } + // source switch { A x => MapToADto(x), B x => MapToBDto(x) } var (typeArmContext, typeArmVariableName) = ctx.WithNewSource(); var arms = _typeMappings .Select(x => BuildSwitchArm(typeArmVariableName, x.SourceType, x.Build(typeArmContext))) @@ -43,7 +43,7 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx) private SwitchExpressionArmSyntax BuildSwitchArm(string typeArmVariableName, ITypeSymbol type, ExpressionSyntax mapping) { - // A x => MapToA(x), + // A x => MapToADto(x), var declaration = DeclarationPattern(FullyQualifiedIdentifier(type), SingleVariableDesignation(Identifier(typeArmVariableName))); return SwitchExpressionArm(declaration, mapping); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/ExistingTarget/UserDefinedExistingTargetMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ExistingTarget/UserDefinedExistingTargetMethodMapping.cs index 414b7b0e95..88ba8a38b0 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/ExistingTarget/UserDefinedExistingTargetMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/ExistingTarget/UserDefinedExistingTargetMethodMapping.cs @@ -26,17 +26,12 @@ public UserDefinedExistingTargetMethodMapping( bool enableReferenceHandling, INamedTypeSymbol referenceHandlerType ) - : base(sourceParameter, targetParameter.Type) + : base(method, sourceParameter, referenceHandlerParameter, targetParameter.Type) { _enableReferenceHandling = enableReferenceHandling; _referenceHandlerType = referenceHandlerType; - IsPartial = true; - IsExtensionMethod = method.IsExtensionMethod; - Accessibility = method.DeclaredAccessibility; Method = method; - MethodName = method.Name; TargetParameter = targetParameter; - ReferenceHandlerParameter = referenceHandlerParameter; } public IMethodSymbol Method { get; } @@ -47,8 +42,6 @@ INamedTypeSymbol referenceHandlerType public override bool CallableByOtherMappings => false; - protected override ITypeSymbol? ReturnType => null; // return type is always void. - public override ExpressionSyntax Build(TypeMappingBuildContext ctx) => throw new InvalidOperationException($"{nameof(UserDefinedExistingTargetMethodMapping)} does not support {nameof(Build)}"); diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberAssignmentMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberAssignmentMapping.cs index 38f8aee180..995aede839 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberAssignmentMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberAssignmentMapping.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Symbols; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.Mappings.MemberMappings; @@ -25,10 +24,8 @@ public MemberAssignmentMapping(MemberPath targetPath, IMemberMapping mapping) public MemberPath TargetPath { get; } - public IEnumerable Build(TypeMappingBuildContext ctx, ExpressionSyntax targetAccess) - { - return new[] { ExpressionStatement(BuildExpression(ctx, targetAccess)), }; - } + public IEnumerable Build(TypeMappingBuildContext ctx, ExpressionSyntax targetAccess) => + SingleStatement(BuildExpression(ctx, targetAccess)); public ExpressionSyntax BuildExpression(TypeMappingBuildContext ctx, ExpressionSyntax? targetAccess) { diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs index 64da3e6ac5..9f97c6f55f 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs @@ -6,6 +6,7 @@ using Riok.Mapperly.Symbols; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; +using Accessibility = Microsoft.CodeAnalysis.Accessibility; namespace Riok.Mapperly.Descriptors.Mappings; @@ -20,41 +21,51 @@ public abstract class MethodMapping : TypeMapping private const int SourceParameterIndex = 0; private const int ReferenceHandlerParameterIndex = 1; + private readonly Accessibility _accessibility = Accessibility.Private; + private readonly ITypeSymbol _returnType; + private string? _methodName; protected MethodMapping(ITypeSymbol sourceType, ITypeSymbol targetType) - : this(new MethodParameter(SourceParameterIndex, DefaultSourceParameterName, sourceType), targetType) { } + : base(sourceType, targetType) + { + SourceParameter = new MethodParameter(SourceParameterIndex, DefaultSourceParameterName, sourceType); + _returnType = targetType; + } - protected MethodMapping(MethodParameter sourceParameter, ITypeSymbol targetType) + protected MethodMapping( + IMethodSymbol method, + MethodParameter sourceParameter, + MethodParameter? referenceHandlerParameter, + ITypeSymbol targetType + ) : base(sourceParameter.Type, targetType) { SourceParameter = sourceParameter; + IsExtensionMethod = method.IsExtensionMethod; + IsPartial = method.IsPartialDefinition; + ReferenceHandlerParameter = referenceHandlerParameter; + _accessibility = method.DeclaredAccessibility; + _methodName = method.Name; + _returnType = method.ReturnType.UpgradeNullable(); } - protected Accessibility Accessibility { get; set; } = Accessibility.Private; + private bool IsPartial { get; } - protected bool IsPartial { get; set; } + protected bool IsExtensionMethod { get; } - protected bool IsExtensionMethod { get; set; } - - protected string MethodName - { - get => _methodName ?? throw new InvalidOperationException(); - set => _methodName = value; - } + private string MethodName => _methodName ?? throw new InvalidOperationException(); protected MethodParameter SourceParameter { get; } - protected MethodParameter? ReferenceHandlerParameter { get; set; } - - protected virtual ITypeSymbol? ReturnType => TargetType; + protected MethodParameter? ReferenceHandlerParameter { get; private set; } public override ExpressionSyntax Build(TypeMappingBuildContext ctx) => Invocation(MethodName, SourceParameter.WithArgument(ctx.Source), ReferenceHandlerParameter?.WithArgument(ctx.ReferenceHandler)); public MethodDeclarationSyntax BuildMethod(SourceEmitterContext ctx) { - TypeSyntax returnType = ReturnType == null ? PredefinedType(Token(SyntaxKind.VoidKeyword)) : FullyQualifiedIdentifier(TargetType); + var returnType = FullyQualifiedIdentifier(_returnType); var typeMappingBuildContext = new TypeMappingBuildContext( SourceParameter.Name, @@ -92,7 +103,7 @@ protected virtual ParameterListSyntax BuildParameterList() => private IEnumerable BuildModifiers(bool isStatic) { - yield return Accessibility(Accessibility); + yield return Accessibility(_accessibility); if (isStatic) yield return Token(SyntaxKind.StaticKeyword); diff --git a/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs index 46a0b6a5d9..84a4277a51 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs @@ -5,7 +5,7 @@ namespace Riok.Mapperly.Descriptors.Mappings; /// -[DebuggerDisplay("{GetType()}({SourceType.Name} => {TargetType.Name})")] +[DebuggerDisplay("{GetType()}({SourceType} => {TargetType})")] public abstract class TypeMapping : ITypeMapping { protected TypeMapping(ITypeSymbol sourceType, ITypeSymbol targetType) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs index 882c6675db..763b180cc8 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs @@ -26,16 +26,11 @@ public UserDefinedNewInstanceMethodMapping( bool enableReferenceHandling, INamedTypeSymbol referenceHandlerType ) - : base(sourceParameter, method.ReturnType.UpgradeNullable()) + : base(method, sourceParameter, referenceHandlerParameter, method.ReturnType.UpgradeNullable()) { _enableReferenceHandling = enableReferenceHandling; _referenceHandlerType = referenceHandlerType; - IsPartial = true; - IsExtensionMethod = method.IsExtensionMethod; - Accessibility = method.DeclaredAccessibility; Method = method; - MethodName = method.Name; - ReferenceHandlerParameter = referenceHandlerParameter; } public IMethodSymbol Method { get; } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceRuntimeTargetTypeMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceRuntimeTargetTypeMapping.cs new file mode 100644 index 0000000000..2362ab21f1 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceRuntimeTargetTypeMapping.cs @@ -0,0 +1,113 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; +using Riok.Mapperly.Symbols; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings; + +/// +/// A mapping which has a as a second parameter describing the target type of the mapping. +/// Generates a switch expression based on the mapping types. +/// +public class UserDefinedNewInstanceRuntimeTargetTypeMapping : MethodMapping, IUserMapping +{ + private const string IsAssignableFromMethodName = nameof(Type.IsAssignableFrom); + private const string GetTypeMethodName = nameof(GetType); + + private readonly MethodParameter _targetTypeParameter; + private readonly List _mappings = new(); + private readonly bool _addNullArm; + private readonly bool _enableReferenceHandling; + private readonly INamedTypeSymbol _referenceHandlerType; + + public UserDefinedNewInstanceRuntimeTargetTypeMapping( + IMethodSymbol method, + RuntimeTargetTypeMappingMethodParameters parameters, + bool enableReferenceHandling, + INamedTypeSymbol referenceHandlerType, + bool addNullArm + ) + : base(method, parameters.Source, parameters.ReferenceHandler, method.ReturnType) + { + Method = method; + _enableReferenceHandling = enableReferenceHandling; + _referenceHandlerType = referenceHandlerType; + _addNullArm = addNullArm; + _targetTypeParameter = parameters.TargetType; + } + + public IMethodSymbol Method { get; } + + public override bool CallableByOtherMappings => false; + + public void AddMappings(IEnumerable mappings) => _mappings.AddRange(mappings); + + public override IEnumerable BuildBody(TypeMappingBuildContext ctx) + { + // if reference handling is enabled and no reference handler parameter is declared + // a new reference handler is instantiated and used. + if (_enableReferenceHandling && ReferenceHandlerParameter == null) + { + // var refHandler = new RefHandler(); + var referenceHandlerName = ctx.NameBuilder.New(DefaultReferenceHandlerParameterName); + var createRefHandler = CreateInstance(_referenceHandlerType); + yield return DeclareLocalVariable(referenceHandlerName, createRefHandler); + ctx = ctx.WithRefHandler(referenceHandlerName); + } + + var targetType = IdentifierName(_targetTypeParameter.Name); + + // _ => throw new ArgumentException(msg, nameof(ctx.Source)), + var sourceType = Invocation(MemberAccess(ctx.Source, GetTypeMethodName)); + var fallbackArm = SwitchExpressionArm( + DiscardPattern(), + ThrowArgumentExpression( + InterpolatedString($"Cannot map {sourceType} to {targetType} as there is no known type mapping"), + ctx.Source + ) + ); + + // source switch { A x when targetType.IsAssignableFrom(typeof(ADto)) => MapToADto(x), B x when targetType.IsAssignableFrom(typeof(BDto)) => MapToBDto(x) } + var (typeArmContext, typeArmVariableName) = ctx.WithNewScopedSource(); + var arms = _mappings.Select(x => BuildSwitchArm(typeArmContext, typeArmVariableName, x, targetType)); + + // null => null + if (_addNullArm) + { + arms = arms.Append(SwitchExpressionArm(ConstantPattern(NullLiteral()), DefaultLiteral())); + } + + arms = arms.Append(fallbackArm); + var switchExpression = SwitchExpression(ctx.Source).WithArms(CommaSeparatedList(arms, true)); + yield return ReturnStatement(switchExpression); + } + + protected override ParameterListSyntax BuildParameterList() => + ParameterList(IsExtensionMethod, SourceParameter, _targetTypeParameter, ReferenceHandlerParameter); + + internal override void EnableReferenceHandling(INamedTypeSymbol iReferenceHandlerType) + { + // the parameters of user defined methods should not be manipulated + } + + private SwitchExpressionArmSyntax BuildSwitchArm( + TypeMappingBuildContext typeArmContext, + string typeArmVariableName, + ITypeMapping mapping, + ExpressionSyntax reflectionTargetType + ) + { + // A x when targetType.IsAssignableFrom(typeof(ADto)) => MapToADto(x), + var declaration = DeclarationPattern( + FullyQualifiedIdentifier(mapping.SourceType.NonNullable()), + SingleVariableDesignation(Identifier(typeArmVariableName)) + ); + var whenCondition = Invocation( + MemberAccess(reflectionTargetType, IsAssignableFromMethodName), + TypeOfExpression(FullyQualifiedIdentifier(mapping.TargetType.NonNullable())) + ); + return SwitchExpressionArm(declaration, mapping.Build(typeArmContext)).WithWhenClause(WhenClause(whenCondition)); + } +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs b/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs new file mode 100644 index 0000000000..860f630e36 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/TypeMappingKey.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Descriptors.Mappings; +using Riok.Mapperly.Descriptors.Mappings.ExistingTarget; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Descriptors; + +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(IExistingTargetMapping mapping, bool includeNullability = true) + : this(mapping.SourceType, mapping.TargetType, includeNullability) { } + + public TypeMappingKey(ITypeSymbol source, ITypeSymbol target, bool includeNullability = true) + { + _source = includeNullability ? source : source.NonNullable(); + _target = includeNullability ? target : target.NonNullable(); + } + + private bool Equals(TypeMappingKey other) => _comparer.Equals(_source, other._source) && _comparer.Equals(_target, other._target); + + public override bool Equals(object? obj) => obj is TypeMappingKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hashCode = _comparer.GetHashCode(_source); + hashCode = (hashCode * 397) ^ _comparer.GetHashCode(_target); + return hashCode; + } + } + + public static bool operator ==(TypeMappingKey left, TypeMappingKey right) => left.Equals(right); + + public static bool operator !=(TypeMappingKey left, TypeMappingKey right) => !left.Equals(right); +} diff --git a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs index 76987f63b6..b2da5cf910 100644 --- a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs @@ -89,6 +89,17 @@ bool isStatic return null; } + if (BuildRuntimeTargetTypeMappingParameters(ctx, methodSymbol, out var runtimeTargetTypeParams)) + { + return new UserDefinedNewInstanceRuntimeTargetTypeMapping( + methodSymbol, + runtimeTargetTypeParams, + ctx.MapperConfiguration.UseReferenceHandling, + ctx.Types.PreserveReferenceHandler, + runtimeTargetTypeParams.Source.Type.IsNullable() && methodSymbol.ReturnType.UpgradeNullable().IsNullable() + ); + } + if (!BuildParameters(ctx, methodSymbol, out var parameters)) { ctx.ReportDiagnostic(DiagnosticDescriptors.UnsupportedMappingMethodSignature, methodSymbol, methodSymbol.Name); @@ -116,11 +127,63 @@ bool isStatic ); } + private static bool BuildRuntimeTargetTypeMappingParameters( + SimpleMappingBuilderContext ctx, + IMethodSymbol method, + out RuntimeTargetTypeMappingMethodParameters parameters + ) + { + var expectedParametersCount = 0; + + // reference handler parameter is always annotated + var refHandlerParameter = BuildReferenceHandlerParameter(ctx, method); + var refHandlerParameterOrdinal = refHandlerParameter?.Ordinal ?? -1; + if (refHandlerParameter.HasValue) + { + expectedParametersCount++; + } + + // source parameter is the first parameter (except if the reference handler is the first parameter) + var sourceParameter = MethodParameter.Wrap(method.Parameters.FirstOrDefault(p => p.Ordinal != refHandlerParameterOrdinal)); + expectedParametersCount++; + if (sourceParameter == null) + { + parameters = default; + return false; + } + + // target type parameter is the second parameter (except if the reference handler is the first or the second parameter) + var targetTypeParameter = MethodParameter.Wrap( + method.Parameters.FirstOrDefault(p => p.Ordinal != sourceParameter.Value.Ordinal && p.Ordinal != refHandlerParameterOrdinal) + ); + expectedParametersCount++; + if (targetTypeParameter == null || !SymbolEqualityComparer.Default.Equals(targetTypeParameter.Value.Type, ctx.Types.Type)) + { + parameters = default; + return false; + } + + if (method.Parameters.Length != expectedParametersCount) + { + parameters = default; + return false; + } + + parameters = new RuntimeTargetTypeMappingMethodParameters(sourceParameter.Value, targetTypeParameter.Value, refHandlerParameter); + return true; + } + private static bool BuildParameters(SimpleMappingBuilderContext ctx, IMethodSymbol method, out MappingMethodParameters parameters) { + var expectedParameterCount = 1; + // reference handler parameter is always annotated var refHandlerParameter = BuildReferenceHandlerParameter(ctx, method); var refHandlerParameterOrdinal = refHandlerParameter?.Ordinal ?? -1; + if (refHandlerParameter.HasValue) + { + expectedParameterCount++; + } // source parameter is the first parameter (except if the reference handler is the first parameter) var sourceParameter = MethodParameter.Wrap(method.Parameters.FirstOrDefault(p => p.Ordinal != refHandlerParameterOrdinal)); @@ -136,7 +199,18 @@ private static bool BuildParameters(SimpleMappingBuilderContext ctx, IMethodSymb var targetParameter = MethodParameter.Wrap( method.Parameters.FirstOrDefault(p => p.Ordinal != sourceParameter.Value.Ordinal && p.Ordinal != refHandlerParameterOrdinal) ); - if (method.ReturnsVoid == (targetParameter == null)) + if (method.ReturnsVoid == !targetParameter.HasValue) + { + parameters = default; + return false; + } + + if (targetParameter.HasValue) + { + expectedParameterCount++; + } + + if (method.Parameters.Length != expectedParameterCount) { parameters = default; return false; diff --git a/src/Riok.Mapperly/Descriptors/WellKnownTypes.cs b/src/Riok.Mapperly/Descriptors/WellKnownTypes.cs index f687ddb696..e992d36771 100644 --- a/src/Riok.Mapperly/Descriptors/WellKnownTypes.cs +++ b/src/Riok.Mapperly/Descriptors/WellKnownTypes.cs @@ -23,7 +23,6 @@ public class WellKnownTypes private INamedTypeSymbol? _iReadOnlyDictionaryT; private INamedTypeSymbol? _iEnumerableT; private INamedTypeSymbol? _enumerable; - private INamedTypeSymbol? _iCollection; private INamedTypeSymbol? _iCollectionT; private INamedTypeSymbol? _iReadOnlyCollectionT; private INamedTypeSymbol? _iListT; @@ -34,6 +33,7 @@ public class WellKnownTypes private INamedTypeSymbol? _keyValuePairT; private INamedTypeSymbol? _dictionaryT; private INamedTypeSymbol? _enum; + private INamedTypeSymbol? _type; private INamedTypeSymbol? _immutableArray; private INamedTypeSymbol? _immutableArrayT; @@ -77,7 +77,6 @@ internal WellKnownTypes(Compilation compilation) public INamedTypeSymbol IReadOnlyDictionaryT => _iReadOnlyDictionaryT ??= GetTypeSymbol(typeof(IReadOnlyDictionary<,>)); public INamedTypeSymbol IEnumerableT => _iEnumerableT ??= GetTypeSymbol(typeof(IEnumerable<>)); public INamedTypeSymbol Enumerable => _enumerable ??= GetTypeSymbol(typeof(Enumerable)); - public INamedTypeSymbol ICollection => _iCollection ??= GetTypeSymbol(typeof(System.Collections.ICollection)); public INamedTypeSymbol ICollectionT => _iCollectionT ??= GetTypeSymbol(typeof(ICollection<>)); public INamedTypeSymbol IReadOnlyCollectionT => _iReadOnlyCollectionT ??= GetTypeSymbol(typeof(IReadOnlyCollection<>)); public INamedTypeSymbol IListT => _iListT ??= GetTypeSymbol(typeof(IList<>)); @@ -88,6 +87,7 @@ internal WellKnownTypes(Compilation compilation) public INamedTypeSymbol KeyValuePairT => _keyValuePairT ??= GetTypeSymbol(typeof(KeyValuePair<,>)); public INamedTypeSymbol DictionaryT => _dictionaryT ??= GetTypeSymbol(typeof(Dictionary<,>)); public INamedTypeSymbol Enum => _enum ??= GetTypeSymbol(typeof(Enum)); + public INamedTypeSymbol Type => _type ??= GetTypeSymbol(typeof(Type)); public INamedTypeSymbol IQueryableT => _iQueryableT ??= GetTypeSymbol(typeof(IQueryable<>)); public INamedTypeSymbol ImmutableArray => _immutableArray ??= GetTypeSymbol(typeof(ImmutableArray)); diff --git a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs index df05c43868..f3d64f4935 100644 --- a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs +++ b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs @@ -376,6 +376,9 @@ public static IdentifierNameSyntax FullyQualifiedIdentifier(ITypeSymbol typeSymb public static string FullyQualifiedIdentifierName(ITypeSymbol typeSymbol) => typeSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + public static IReadOnlyCollection SingleStatement(ExpressionSyntax expression) => + new[] { ExpressionStatement(expression) }; + private static InterpolatedStringTextSyntax InterpolatedStringText(string text) => SyntaxFactory.InterpolatedStringText( Token(SyntaxTriviaList.Empty, SyntaxKind.InterpolatedStringTextToken, text, text, SyntaxTriviaList.Empty) diff --git a/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs b/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs index e770769683..49d36090fa 100644 --- a/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs +++ b/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs @@ -80,13 +80,23 @@ internal static ITypeSymbol NonNullable(this ITypeSymbol symbol) return nonNullable; } + /// + /// If the is a value type, + /// this is a no-op. For any other value, + /// the non-nullable variant is returned. + /// + /// + /// + internal static ITypeSymbol NonNullableReferenceType(this ITypeSymbol symbol) + { + return symbol.IsValueType ? symbol : symbol.NonNullable(); + } + internal static bool IsNullable(this ITypeSymbol symbol) => symbol.NullableAnnotation.IsNullable() || symbol.NonNullableValueType() is not null; internal static bool IsNullableValueType(this ITypeSymbol symbol) => symbol.NonNullableValueType() is not null; - internal static bool IsNullable(this IPropertySymbol symbol) => symbol.NullableAnnotation.IsNullable() || symbol.Type.IsNullable(); - internal static bool IsNullable(this NullableAnnotation nullable) => nullable is NullableAnnotation.Annotated or NullableAnnotation.None; diff --git a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs index bba5646f98..fcebc462db 100644 --- a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs +++ b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs @@ -23,6 +23,18 @@ internal static bool HasAccessibleParameterlessConstructor(this ITypeSymbol symb symbol is INamedTypeSymbol { IsAbstract: false } namedTypeSymbol && namedTypeSymbol.Constructors.Any(c => c.Parameters.IsDefaultOrEmpty && c.IsAccessible(allowProtected)); + internal static int GetInheritanceLevel(this ITypeSymbol symbol) + { + var level = 0; + while (symbol.BaseType != null) + { + symbol = symbol.BaseType; + level++; + } + + return level; + } + internal static bool IsArrayType(this ITypeSymbol symbol) => symbol is IArrayTypeSymbol; internal static bool IsEnum(this ITypeSymbol t) => TryGetEnumUnderlyingType(t, out _); @@ -152,7 +164,7 @@ internal static bool HasImplicitGenericImplementation(this ITypeSymbol symbol, I symbol.ImplementsGeneric(inter, methodName, out _, out var isExplicit) && !isExplicit; internal static bool IsAssignableTo(this ITypeSymbol symbol, Compilation compilation, ITypeSymbol type) => - compilation.ClassifyConversion(symbol, type).IsImplicit; + compilation.ClassifyConversion(symbol, type).IsImplicit && (type.IsNullable() || !symbol.IsNullable()); internal static bool CanConsumeType(this ITypeParameterSymbol typeParameter, Compilation compilation, ITypeSymbol type) { diff --git a/src/Riok.Mapperly/Symbols/MappingMethodParameters.cs b/src/Riok.Mapperly/Symbols/MappingMethodParameters.cs index bbe773505f..982491c984 100644 --- a/src/Riok.Mapperly/Symbols/MappingMethodParameters.cs +++ b/src/Riok.Mapperly/Symbols/MappingMethodParameters.cs @@ -3,18 +3,4 @@ namespace Riok.Mapperly.Symbols; /// /// Well known mapping method parameters. /// -public readonly struct MappingMethodParameters -{ - public MappingMethodParameters(MethodParameter source, MethodParameter? target, MethodParameter? referenceHandler) - { - Source = source; - Target = target; - ReferenceHandler = referenceHandler; - } - - public MethodParameter Source { get; } - - public MethodParameter? Target { get; } - - public MethodParameter? ReferenceHandler { get; } -} +public record struct MappingMethodParameters(MethodParameter Source, MethodParameter? Target, MethodParameter? ReferenceHandler); diff --git a/src/Riok.Mapperly/Symbols/MethodArgument.cs b/src/Riok.Mapperly/Symbols/MethodArgument.cs index ff4d5e45cd..0037a93ad5 100644 --- a/src/Riok.Mapperly/Symbols/MethodArgument.cs +++ b/src/Riok.Mapperly/Symbols/MethodArgument.cs @@ -5,15 +5,4 @@ namespace Riok.Mapperly.Symbols; /// /// A method argument (a parameter and an argument value). /// -public readonly struct MethodArgument -{ - public MethodArgument(MethodParameter parameter, ExpressionSyntax argument) - { - Parameter = parameter; - Argument = argument; - } - - public MethodParameter Parameter { get; } - - public ExpressionSyntax Argument { get; } -} +public readonly record struct MethodArgument(MethodParameter Parameter, ExpressionSyntax Argument); diff --git a/src/Riok.Mapperly/Symbols/MethodParameter.cs b/src/Riok.Mapperly/Symbols/MethodParameter.cs index df5ea1b22d..6f828d030e 100644 --- a/src/Riok.Mapperly/Symbols/MethodParameter.cs +++ b/src/Riok.Mapperly/Symbols/MethodParameter.cs @@ -4,24 +4,11 @@ namespace Riok.Mapperly.Symbols; -public readonly struct MethodParameter +public readonly record struct MethodParameter(int Ordinal, string Name, ITypeSymbol Type) { - public MethodParameter(int ordinal, string name, ITypeSymbol type) - { - Ordinal = ordinal; - Name = name; - Type = type; - } - public MethodParameter(IParameterSymbol symbol) : this(symbol.Ordinal, symbol.Name, symbol.Type.UpgradeNullable()) { } - public int Ordinal { get; } - - public string Name { get; } - - public ITypeSymbol Type { get; } - public MethodArgument WithArgument(ExpressionSyntax? argument) => new(this, argument ?? throw new ArgumentNullException(nameof(argument))); diff --git a/src/Riok.Mapperly/Symbols/RuntimeTargetTypeMappingMethodParameters.cs b/src/Riok.Mapperly/Symbols/RuntimeTargetTypeMappingMethodParameters.cs new file mode 100644 index 0000000000..6e5d2a1987 --- /dev/null +++ b/src/Riok.Mapperly/Symbols/RuntimeTargetTypeMappingMethodParameters.cs @@ -0,0 +1,12 @@ +using Riok.Mapperly.Descriptors.Mappings; + +namespace Riok.Mapperly.Symbols; + +/// +/// method parameters. +/// +public record struct RuntimeTargetTypeMappingMethodParameters( + MethodParameter Source, + MethodParameter TargetType, + MethodParameter? ReferenceHandler +); diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/StaticTestMapper.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/StaticTestMapper.cs index 18413498ff..5a8621593d 100644 --- a/test/Riok.Mapperly.IntegrationTests/Mapper/StaticTestMapper.cs +++ b/test/Riok.Mapperly.IntegrationTests/Mapper/StaticTestMapper.cs @@ -11,6 +11,8 @@ public static partial class StaticTestMapper { public static partial int DirectInt(int value); + public static partial int? DirectIntNullable(int? value); + public static partial long ImplicitCastInt(int value); public static partial int ExplicitCastInt(uint value); @@ -80,5 +82,9 @@ public static TestObjectDto MapToDto(TestObject src) [MapDerivedType(typeof(int), typeof(string))] #endif public static partial object DerivedTypes(object source); + + public static partial object MapWithRuntimeTargetType(object source, Type targetType); + + public static partial object? MapNullableWithRuntimeTargetType(object? source, Type targetType); } } diff --git a/test/Riok.Mapperly.IntegrationTests/StaticMapperTest.cs b/test/Riok.Mapperly.IntegrationTests/StaticMapperTest.cs index 7aab912fa7..0001878ab0 100644 --- a/test/Riok.Mapperly.IntegrationTests/StaticMapperTest.cs +++ b/test/Riok.Mapperly.IntegrationTests/StaticMapperTest.cs @@ -38,5 +38,17 @@ public void DerivedTypesShouldWork() StaticTestMapper.DerivedTypes("10").Should().Be(10); StaticTestMapper.DerivedTypes(10).Should().Be("10"); } + + [Fact] + public void RuntimeTargetTypeShouldWork() + { + StaticTestMapper.MapWithRuntimeTargetType("10", typeof(int)).Should().Be(10); + } + + [Fact] + public void NullableRuntimeTargetTypeWithNullShouldReturnNull() + { + StaticTestMapper.MapNullableWithRuntimeTargetType(null, typeof(int?)).Should().BeNull(); + } } } diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/NET_48/StaticMapperTest.SnapshotGeneratedSource.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/NET_48/StaticMapperTest.SnapshotGeneratedSource.verified.cs index d24c97721b..05c6375b14 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/NET_48/StaticMapperTest.SnapshotGeneratedSource.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/NET_48/StaticMapperTest.SnapshotGeneratedSource.verified.cs @@ -8,6 +8,11 @@ public static partial int DirectInt(int value) return value; } + public static partial int? DirectIntNullable(int? value) + { + return value == null ? default : value.Value; + } + public static partial long ImplicitCastInt(int value) { return (long)value; @@ -330,6 +335,45 @@ public static partial object DerivedTypes(object source) }; } + public static partial object MapWithRuntimeTargetType(object source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + + public static partial object? MapNullableWithRuntimeTargetType(object? source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + null => default, + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + private static global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto MapToTestObjectNestedDto(global::Riok.Mapperly.IntegrationTests.Models.TestObjectNested source) { var target = new global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto(); diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_4_OR_LOWER/StaticMapperTest.SnapshotGeneratedSource.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_4_OR_LOWER/StaticMapperTest.SnapshotGeneratedSource.verified.cs index 75aeaeec5e..5c68b583d1 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_4_OR_LOWER/StaticMapperTest.SnapshotGeneratedSource.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_4_OR_LOWER/StaticMapperTest.SnapshotGeneratedSource.verified.cs @@ -8,6 +8,11 @@ public static partial int DirectInt(int value) return value; } + public static partial int? DirectIntNullable(int? value) + { + return value == null ? default : value.Value; + } + public static partial long ImplicitCastInt(int value) { return (long)value; @@ -321,6 +326,45 @@ public static partial object DerivedTypes(object source) }; } + public static partial object MapWithRuntimeTargetType(object source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + + public static partial object? MapNullableWithRuntimeTargetType(object? source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + null => default, + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + private static global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto MapToTestObjectNestedDto(global::Riok.Mapperly.IntegrationTests.Models.TestObjectNested source) { var target = new global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto(); diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_5/StaticMapperTest.SnapshotGeneratedSource.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_5/StaticMapperTest.SnapshotGeneratedSource.verified.cs index 6daab05311..8a50d8a15e 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_5/StaticMapperTest.SnapshotGeneratedSource.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/Roslyn_4_5/StaticMapperTest.SnapshotGeneratedSource.verified.cs @@ -8,6 +8,11 @@ public static partial int DirectInt(int value) return value; } + public static partial int? DirectIntNullable(int? value) + { + return value == null ? default : value.Value; + } + public static partial long ImplicitCastInt(int value) { return (long)value; @@ -330,6 +335,45 @@ public static partial object DerivedTypes(object source) }; } + public static partial object MapWithRuntimeTargetType(object source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + + public static partial object? MapNullableWithRuntimeTargetType(object? source, global::System.Type targetType) + { + return source switch + { + global::Riok.Mapperly.IntegrationTests.Models.TestEnum x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByName)) => MapToEnumDtoByName(x), + int x when targetType.IsAssignableFrom(typeof(int)) => DirectInt(x), + int x when targetType.IsAssignableFrom(typeof(long)) => ImplicitCastInt(x), + uint x when targetType.IsAssignableFrom(typeof(int)) => ExplicitCastInt(x), + global::System.DateTime x when targetType.IsAssignableFrom(typeof(global::System.DateTime)) => DirectDateTime(x), + string x when targetType.IsAssignableFrom(typeof(global::System.Guid)) => ParseableGuid(x), + string x when targetType.IsAssignableFrom(typeof(int)) => ParseableInt(x), + global::Riok.Mapperly.IntegrationTests.Models.TestObject x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto)) => MapToDtoExt(x), + global::Riok.Mapperly.IntegrationTests.Dto.TestObjectDto x when targetType.IsAssignableFrom(typeof(global::Riok.Mapperly.IntegrationTests.Models.TestObject)) => MapFromDto(x), + global::System.Collections.Generic.IEnumerable x when targetType.IsAssignableFrom(typeof(global::System.Collections.Generic.IEnumerable)) => MapAllDtos(x), + object x when targetType.IsAssignableFrom(typeof(object)) => DerivedTypes(x), + null => default, + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + private static global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto MapToTestObjectNestedDto(global::Riok.Mapperly.IntegrationTests.Models.TestObjectNested source) { var target = new global::Riok.Mapperly.IntegrationTests.Dto.TestObjectNestedDto(); diff --git a/test/Riok.Mapperly.Tests/Mapping/ReferenceHandlingTest.cs b/test/Riok.Mapperly.Tests/Mapping/ReferenceHandlingTest.cs index a12d9951ec..c52839bf24 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ReferenceHandlingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ReferenceHandlingTest.cs @@ -99,6 +99,23 @@ public Task ObjectFactoryShouldWork() return TestHelper.VerifyGenerator(source); } + [Fact] + public Task RuntimeTargetTypeShouldWork() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object Map(object source, Type destinationType); + + private partial B MapToB(A source, [ReferenceHandler] IReferenceHandler refHandler); + """, + TestSourceBuilderOptions.WithReferenceHandling, + "class A {}", + "class B {}" + ); + + return TestHelper.VerifyGenerator(source); + } + [Fact] public Task CustomHandlerShouldWork() { @@ -138,6 +155,23 @@ public void CustomHandlerWithWrongTypeShouldDiagnostic() ); } + [Fact] + public Task RuntimeTargetTypeWithReferenceHandlingShouldWork() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object Map(object source, Type destinationType, [ReferenceHandler] IReferenceHandler refHandler); + + private partial B MapToB(A source, [ReferenceHandler] IReferenceHandler refHandler); + """, + TestSourceBuilderOptions.WithReferenceHandling, + "class A {}", + "class B {}" + ); + + return TestHelper.VerifyGenerator(source); + } + [Fact] public void CustomHandlerWithDisabledReferenceHandlingShouldDiagnostic() { diff --git a/test/Riok.Mapperly.Tests/Mapping/RuntimeTargetTypeMappingTest.cs b/test/Riok.Mapperly.Tests/Mapping/RuntimeTargetTypeMappingTest.cs new file mode 100644 index 0000000000..0a6637d8e3 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/RuntimeTargetTypeMappingTest.cs @@ -0,0 +1,222 @@ +using Riok.Mapperly.Diagnostics; + +namespace Riok.Mapperly.Tests.Mapping; + +[UsesVerify] +public class RuntimeTargetTypeMappingTest +{ + [Fact] + public Task WithNullableObjectSourceAndTargetTypeShouldIncludeNullables() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object? Map(object? source, Type targetType); + + private partial B MapToB(A source); + private partial D? MapToD(C? source); + private partial int? MapStringToInt(string? source); + private partial int? MapIntToInt(int source); + """, + "class A { public string Value { get; set; } }", + "class B { public string Value { get; set; } }", + "class C { public string Value2 { get; set; } }", + "class D { public string Value2 { get; set; } }" + ); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public void WithNonNullableReturnTypeShouldOnlyIncludeNonNullableMappings() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object Map(object source, Type targetType); + + private partial B MapToB(A source); + private partial D? MapToD(C source); + private partial int? MapToInt(string? source); + """, + "class A {}", + "class B {}", + "class C {}", + "class D {}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + return source switch + { + global::A x when targetType.IsAssignableFrom(typeof(global::B)) => MapToB(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + """ + ); + } + + [Fact] + public void WithSubsetSourceTypeAndObjectTargetTypeShouldWork() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial Base1Target Map(Base1Source source, Type targetType); + + private partial B MapToB(A source); + private partial D MapToD(C source); + """, + "class Base1Source {}", + "class Base2Source {}", + "class Base1Target {}", + "class Base2Target {}", + "class A : Base1Source {}", + "class B : Base1Target {}", + "class C : Base2Source {}", + "class D : Base2Target {}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + return source switch + { + global::A x when targetType.IsAssignableFrom(typeof(global::B)) => MapToB(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + """ + ); + } + + [Fact] + public void WithTypeHierarchyShouldPreferMostSpecificMapping() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object Map(object source, Type targetType); + + private partial C MapAToC(A source); + private partial C MapBToC(B source); + private partial C MapB1ToC(Base1 source); + private partial C MapB2ToC(Base2 source); + """, + "class Base1 {}", + "class Base2 : Base1 {}", + "class A : Base2 {}", + "class B : Base1 {}", + "class C {}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + return source switch + { + global::A x when targetType.IsAssignableFrom(typeof(global::C)) => MapAToC(x), + global::B x when targetType.IsAssignableFrom(typeof(global::C)) => MapBToC(x), + global::Base2 x when targetType.IsAssignableFrom(typeof(global::C)) => MapB2ToC(x), + global::Base1 x when targetType.IsAssignableFrom(typeof(global::C)) => MapB1ToC(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + """ + ); + } + + [Fact] + public void WithDerivedTypesShouldUseBaseType() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public partial object Map(object source, Type targetType); + + [MapDerivedType] + [MapDerivedType] + partial BaseDto MapDerivedTypes(Base source); + """, + "class Base {}", + "class BaseDto {}", + "class A : Base {}", + "class B : BaseDto {}", + "class C : Base {}", + "class D : BaseDto {}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + return source switch + { + global::Base x when targetType.IsAssignableFrom(typeof(global::BaseDto)) => MapDerivedTypes(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + """ + ); + } + + [Fact] + public void WithDerivedTypesOnSameMethodAndDuplicatedSourceTypeShouldIncludeAll() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapDerivedType] + [MapDerivedType] + [MapDerivedType] + [MapDerivedType] + public partial object Map(object source, Type targetType); + """, + "class Base {}", + "class BaseDto {}", + "class A : Base {}", + "class B : BaseDto {}", + "class C : Base {}", + "class D : BaseDto {}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + return source switch + { + global::A x when targetType.IsAssignableFrom(typeof(global::B)) => MapToB(x), + global::A x when targetType.IsAssignableFrom(typeof(global::D)) => MapToD(x), + global::C x when targetType.IsAssignableFrom(typeof(global::B)) => MapToB1(x), + global::C x when targetType.IsAssignableFrom(typeof(global::D)) => MapToD1(x), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + """ + ); + } + + [Fact] + public void InvalidSignatureAdditionalParameterShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial object Map(A a, Type targetType, string format);", + "class A { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowAllDiagnostics) + .Should() + .HaveDiagnostic(new(DiagnosticDescriptors.UnsupportedMappingMethodSignature)); + } + + [Fact] + public void InvalidSignatureWithReferenceHandlerAdditionalParameterShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial object Map(A a, Type targetType, [ReferenceHandler] IReferenceHandler refHanlder, string format);", + TestSourceBuilderOptions.WithReferenceHandling, + "class A { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowAllDiagnostics) + .Should() + .HaveDiagnostic(new(DiagnosticDescriptors.UnsupportedMappingMethodSignature)); + } +} diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs index b433048229..52bc9af8e6 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs @@ -206,7 +206,7 @@ public void WithSameNamesShouldGenerateUniqueMethodNames() } [Fact] - public void WithInvalidSignatureShouldDiagnostic() + public void InvalidSignatureReturnTypeAdditionalParameterShouldDiagnostic() { var source = TestSourceBuilder.MapperWithBodyAndTypes("partial string ToString(T source, string format);"); @@ -221,6 +221,52 @@ public void WithInvalidSignatureShouldDiagnostic() ); } + [Fact] + public void InvalidSignatureAdditionalParameterShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial void Map(A a, B b, string format);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowAllDiagnostics) + .Should() + .HaveDiagnostic(new(DiagnosticDescriptors.UnsupportedMappingMethodSignature)); + } + + [Fact] + public void InvalidSignatureAdditionalParameterWithReferenceHandlingShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial void Map(A a, B b, [ReferenceHandler] IReferenceHandler refHandler, string format);", + TestSourceBuilderOptions.WithReferenceHandling, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowAllDiagnostics) + .Should() + .HaveDiagnostic(new(DiagnosticDescriptors.UnsupportedMappingMethodSignature)); + } + + [Fact] + public void InvalidSignatureAsyncShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial async Task Map(A a);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowAllDiagnostics) + .Should() + .HaveDiagnostic(new(DiagnosticDescriptors.UnsupportedMappingMethodSignature)); + } + [Fact] public void WithInvalidGenericSignatureShouldDiagnostic() { diff --git a/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeShouldWork#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeShouldWork#Mapper.g.verified.cs new file mode 100644 index 0000000000..9610143f08 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeShouldWork#Mapper.g.verified.cs @@ -0,0 +1,23 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + public partial object Map(object source, global::System.Type destinationType) + { + var refHandler = new global::Riok.Mapperly.Abstractions.ReferenceHandling.Internal.PreserveReferenceHandler(); + return source switch + { + global::A x when destinationType.IsAssignableFrom(typeof(global::B)) => MapToB(x, refHandler), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {destinationType} as there is no known type mapping", nameof(source)), + }; + } + + private partial global::B MapToB(global::A source, global::Riok.Mapperly.Abstractions.ReferenceHandling.IReferenceHandler refHandler) + { + if (refHandler.TryGetReference(source, out var existingTargetReference)) + return existingTargetReference; + var target = new global::B(); + refHandler.SetReference(source, target); + return target; + } +} diff --git a/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeWithReferenceHandlingShouldWork#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeWithReferenceHandlingShouldWork#Mapper.g.verified.cs new file mode 100644 index 0000000000..d3282d2535 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ReferenceHandlingTest.RuntimeTargetTypeWithReferenceHandlingShouldWork#Mapper.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + public partial object Map(object source, global::System.Type destinationType, global::Riok.Mapperly.Abstractions.ReferenceHandling.IReferenceHandler refHandler) + { + return source switch + { + global::A x when destinationType.IsAssignableFrom(typeof(global::B)) => MapToB(x, refHandler), + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {destinationType} as there is no known type mapping", nameof(source)), + }; + } + + private partial global::B MapToB(global::A source, global::Riok.Mapperly.Abstractions.ReferenceHandling.IReferenceHandler refHandler) + { + if (refHandler.TryGetReference(source, out var existingTargetReference)) + return existingTargetReference; + var target = new global::B(); + refHandler.SetReference(source, target); + return target; + } +} diff --git a/test/Riok.Mapperly.Tests/_snapshots/RuntimeTargetTypeMappingTest.WithNullableObjectSourceAndTargetTypeShouldIncludeNullables#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/RuntimeTargetTypeMappingTest.WithNullableObjectSourceAndTargetTypeShouldIncludeNullables#Mapper.g.verified.cs new file mode 100644 index 0000000000..689807e4c7 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/RuntimeTargetTypeMappingTest.WithNullableObjectSourceAndTargetTypeShouldIncludeNullables#Mapper.g.verified.cs @@ -0,0 +1,43 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + public partial object? Map(object? source, global::System.Type targetType) + { + return source switch + { + int x when targetType.IsAssignableFrom(typeof(int)) => MapIntToInt(x), + string x when targetType.IsAssignableFrom(typeof(int)) => MapStringToInt(x), + global::A x when targetType.IsAssignableFrom(typeof(global::B)) => MapToB(x), + global::C x when targetType.IsAssignableFrom(typeof(global::D)) => MapToD(x), + null => default, + _ => throw new System.ArgumentException($"Cannot map {source.GetType()} to {targetType} as there is no known type mapping", nameof(source)), + }; + } + + private partial global::B MapToB(global::A source) + { + var target = new global::B(); + target.Value = source.Value; + return target; + } + + private partial global::D? MapToD(global::C? source) + { + if (source == null) + return default; + var target = new global::D(); + target.Value2 = source.Value2; + return target; + } + + private partial int? MapStringToInt(string? source) + { + return source == null ? default : int.Parse(source); + } + + private partial int? MapIntToInt(int source) + { + return (int?)source; + } +}