diff --git a/.gitignore b/.gitignore index fac5ba60ab..f76ed78f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ bin/ obj/ +.vs/ + # verify received files *.received.* diff --git a/docs/docs/02-configuration/07-ctor-mappings.md b/docs/docs/02-configuration/07-ctor-mappings.md index 42a3285ae4..52c9b313f5 100644 --- a/docs/docs/02-configuration/07-ctor-mappings.md +++ b/docs/docs/02-configuration/07-ctor-mappings.md @@ -10,3 +10,58 @@ Mapperly resolves the constructor to be used by the following priorities: The first constructor which allows the mapping of all parameters is used. Constructor parameters are mapped in a case insensitive matter. + +## Manual mapping configuration + +Manual mapping of source properties to constructor parameters can be configured with the `MapProperty` attribute. +See example below + +```csharp +public class Car +{ + // highlight-start + public string ModelName { get; set; } + // highlight-end +} + +public record CarDto(string Model); + +[Mapper] +public partial class CarMapper +{ + // highlight-start + [MapProperty(nameof(Car.ModelName), nameof(CarDto.Model))] + // highlight-end + public partial CarDto ToDto(Car car); +} +``` + +If the target type is a normal class, where the constructor parameter name not necessarily matches the property name, you can identify the correct parameter name using a string literal. + +```csharp +public class Car +{ + public string ModelName { get; set; } +} + +public class CarDto +{ + // highlight-start + public CarDto(string model) + // highlight-end + { + ModelName = model; + } + + public string ModelName { get; } +} + +[Mapper] +public partial class CarMapper +{ + // highlight-start + [MapProperty(nameof(Car.ModelName), "model")] + // highlight-end + public partial CarDto ToDto(Car car); +} +``` diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index 6f994165d9..1dac6409d0 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -65,3 +65,5 @@ RMG025 | Mapper | Error | To use reference handling it needs to be enabled Rule ID | Category | Severity | Notes --------|----------|----------|------- RMG026 | Mapper | Info | Cannot map from indexed property +RMG027 | Mapper | Warning | A constructor parameter can have one configuration at max +RMG028 | Mapper | Error | Constructor parameter cannot handle target paths diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilder/NewInstanceObjectPropertyMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilder/NewInstanceObjectPropertyMappingBodyBuilder.cs index 12c27f3224..b81d9964d9 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilder/NewInstanceObjectPropertyMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilder/NewInstanceObjectPropertyMappingBodyBuilder.cs @@ -197,40 +197,86 @@ private static bool TryBuildConstructorMapping( mappedTargetPropertyNames = new HashSet(); var skippedOptionalParam = false; foreach (var parameter in ctor.Parameters) - { - if (!PropertyPath.TryFind( - ctx.Mapping.SourceType, - MemberPathCandidateBuilder.BuildMemberPathCandidates(parameter.Name), - ctx.IgnoredSourcePropertyNames, - StringComparer.OrdinalIgnoreCase, - out var sourcePath)) - { - if (!parameter.IsOptional) - return false; - - skippedOptionalParam = true; - continue; - } - - // nullability is handled inside the property mapping - var paramType = parameter.Type.WithNullableAnnotation(parameter.NullableAnnotation); - var delegateMapping = ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType) - ?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType); - if (delegateMapping == null) - { - if (!parameter.IsOptional) - return false; - - skippedOptionalParam = true; - continue; - } - - var propertyMapping = new NullPropertyMapping(delegateMapping, sourcePath, ctx.BuilderContext.GetNullFallbackValue(paramType)); - var ctorMapping = new ConstructorParameterMapping(parameter, propertyMapping, skippedOptionalParam); - constructorParameterMappings.Add(ctorMapping); + { + if (!TryFindConstructorParameterSourcePath(ctx, parameter, out var sourcePath)) + { + if (!parameter.IsOptional) + return false; + + skippedOptionalParam = true; + continue; + } + + // nullability is handled inside the property mapping + var paramType = parameter.Type.WithNullableAnnotation(parameter.NullableAnnotation); + var delegateMapping = ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType) + ?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType); + if (delegateMapping == null) + { + if (!parameter.IsOptional) + return false; + + skippedOptionalParam = true; + continue; + } + + var propertyMapping = new NullPropertyMapping(delegateMapping, sourcePath, ctx.BuilderContext.GetNullFallbackValue(paramType)); + var ctorMapping = new ConstructorParameterMapping(parameter, propertyMapping, skippedOptionalParam); + constructorParameterMappings.Add(ctorMapping); mappedTargetPropertyNames.Add(parameter.Name); } return true; } + + private static bool TryFindConstructorParameterSourcePath( + NewInstanceMappingBuilderContext ctx, + IParameterSymbol parameter, + [NotNullWhen(true)] out PropertyPath? sourcePath) + { + sourcePath = null; + + if (!ctx.PropertyConfigsByRootTargetName.TryGetValue(parameter.Name, out var propertyConfigs)) + { + return PropertyPath.TryFind( + ctx.Mapping.SourceType, + MemberPathCandidateBuilder.BuildMemberPathCandidates(parameter.Name), + ctx.IgnoredSourcePropertyNames, + StringComparer.OrdinalIgnoreCase, + out sourcePath + ); + } + + if (propertyConfigs.Count > 1) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.MultipleConfigurationsForConstructorParameter, + parameter.Type, + parameter.Name); + } + + var propertyConfig = propertyConfigs.First(); + if (propertyConfig.Target.Count > 1) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.ConstructorParameterDoesNotSupportPaths, + parameter.Type, + string.Join(".", propertyConfig.Target)); + return false; + } + + if (!PropertyPath.TryFind( + ctx.Mapping.SourceType, + propertyConfig.Source, + out sourcePath)) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.MappingSourcePropertyNotFound, + propertyConfig.Source, + ctx.Mapping.SourceType); + return false; + } + + return true; + } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/NewInstanceMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/NewInstanceMappingBuilderContext.cs index 53bbc20bcc..638091d751 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/NewInstanceMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/NewInstanceMappingBuilderContext.cs @@ -17,6 +17,8 @@ public void AddInitPropertyMapping(PropertyAssignmentMapping mapping) public void AddConstructorParameterMapping(ConstructorParameterMapping mapping) { + PropertyConfigsByRootTargetName.Remove(mapping.Parameter.Name); + SetSourcePropertyMapped(mapping.DelegateMapping.SourcePath); Mapping.AddConstructorParameterMapping(mapping); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/ConstructorParameterMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/ConstructorParameterMapping.cs index 410a137c2d..f3e47d140a 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/ConstructorParameterMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/ConstructorParameterMapping.cs @@ -6,7 +6,6 @@ namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; public class ConstructorParameterMapping { - private readonly IParameterSymbol _parameter; private readonly bool _selfOrPreviousIsUnmappedOptional; public ConstructorParameterMapping( @@ -15,10 +14,12 @@ public ConstructorParameterMapping( bool selfOrPreviousIsUnmappedOptional) { DelegateMapping = delegateMapping; - _parameter = parameter; + Parameter = parameter; _selfOrPreviousIsUnmappedOptional = selfOrPreviousIsUnmappedOptional; } + public IParameterSymbol Parameter { get; } + public NullPropertyMapping DelegateMapping { get; } public ArgumentSyntax BuildArgument(TypeMappingBuildContext ctx) @@ -26,12 +27,12 @@ public ArgumentSyntax BuildArgument(TypeMappingBuildContext ctx) var argumentExpression = DelegateMapping.Build(ctx); var arg = Argument(argumentExpression); return _selfOrPreviousIsUnmappedOptional - ? arg.WithNameColon(NameColon(_parameter.Name)) + ? arg.WithNameColon(NameColon(Parameter.Name)) : arg; } protected bool Equals(ConstructorParameterMapping other) - => _parameter.Equals(other._parameter, SymbolEqualityComparer.Default) + => Parameter.Equals(other.Parameter, SymbolEqualityComparer.Default) && DelegateMapping.Equals(other.DelegateMapping) && _selfOrPreviousIsUnmappedOptional == other._selfOrPreviousIsUnmappedOptional; @@ -59,7 +60,7 @@ public override int GetHashCode() { unchecked { - var hashCode = SymbolEqualityComparer.Default.GetHashCode(_parameter); + var hashCode = SymbolEqualityComparer.Default.GetHashCode(Parameter); hashCode = (hashCode * 397) ^ DelegateMapping.GetHashCode(); hashCode = (hashCode * 397) ^ _selfOrPreviousIsUnmappedOptional.GetHashCode(); return hashCode; diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 5955b53b40..91b5bfb017 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -213,4 +213,20 @@ internal static class DiagnosticDescriptors DiagnosticCategories.Mapper, DiagnosticSeverity.Info, true); + + public static readonly DiagnosticDescriptor MultipleConfigurationsForConstructorParameter = new DiagnosticDescriptor( + "RMG027", + "A constructor parameter can have one configuration at max", + "The constructor parameter at {0}.{1} can have one configuration at max", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Warning, + true); + + public static readonly DiagnosticDescriptor ConstructorParameterDoesNotSupportPaths = new DiagnosticDescriptor( + "RMG028", + "Constructor parameter cannot handle target paths", + "Cannot map to constructor parameter target path {0}.{1}", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true); } diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs index 9ffbe42439..13cfa2cb5c 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyConstructorResolverTest.cs @@ -349,5 +349,37 @@ public void RecordToRecordFlattenedNonDirectAssignmentNullHandling() .Should() .HaveSingleMethodBody(@"var target = new B(source.Nested == null ? throw new System.ArgumentNullException(nameof(source.Nested.Value)) : (double)source.Nested.Value); return target;"); + } + + [Fact] + public void CanResolveToRecordConstructorWithMapPropertyAttribute() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(A.Id), nameof(B.Id2))] partial B ToRecord(A a);", + "class A { public string? Id { get; set; } public bool F { get; set; } }", + "record B(string? Id2, bool F);" + ); + + var result = TestHelper.GenerateMapper(source); + result + .Should() + .HaveSingleMethodBody(@"var target = new B(a.Id ?? default, a.F); + return target;"); + } + + [Fact] + public void CanResolveToClassConstructorWithMapPropertyAttribute() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(A.Id), \"id2\")] partial B ToRecord(A a);", + "class A { public string? Id { get; set; } public bool F { get; set; } }", + "class B { public B(string? id2, bool f) { Id2 = id2; F = f; } public string? Id2 { get; set; } public bool F { get; set; } }" + ); + + var result = TestHelper.GenerateMapper(source); + result + .Should() + .HaveSingleMethodBody(@"var target = new B(a.Id ?? default, a.F); + return target;"); } }