From 870a77efd1f33ca5e73f7a94c4ca3fdccc671c5a Mon Sep 17 00:00:00 2001 From: Julian Carrivick Date: Sat, 18 Apr 2026 12:09:55 +0100 Subject: [PATCH] feat: auto-detect user-defined void methods with ref target parameter for property mapping When a user defines a void mapping method with a `ref` target parameter, Mapperly now automatically uses it for property-level mappings of those types, without requiring explicit `[MapProperty(Use = nameof(...))]` opt-in. A new-instance user mapping for the same types still takes precedence, and same-type mappings are excluded to avoid shadowing direct assignment. A method decorated with `[UseMapper]`/`Default = true` overrides this precedence. Adds `HasExistingTargetRefUserMapping` on `MappingBuilderContext` and an `IsRefTarget` property on `UserImplementedExistingTargetMethodMapping`. --- .../user-implemented-methods.mdx | 54 +++++++-- .../ObjectMemberMappingBodyBuilder.cs | 4 +- .../Descriptors/MappingBuilderContext.cs | 25 ++++ ...rImplementedExistingTargetMethodMapping.cs | 2 + .../Mapper/UseUserMethodWithRefAutoDetect.cs | 19 +++ .../UseUserMethodWithRefAutoDetectTest.cs | 24 ++++ .../Mapping/UserMethodTest.cs | 112 ++++++++++++++++++ 7 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 test/Riok.Mapperly.IntegrationTests/Mapper/UseUserMethodWithRefAutoDetect.cs create mode 100644 test/Riok.Mapperly.IntegrationTests/UseUserMethodWithRefAutoDetectTest.cs diff --git a/docs/docs/configuration/user-implemented-methods.mdx b/docs/docs/configuration/user-implemented-methods.mdx index 6fded16a95..d8779dba93 100644 --- a/docs/docs/configuration/user-implemented-methods.mdx +++ b/docs/docs/configuration/user-implemented-methods.mdx @@ -99,17 +99,53 @@ which can be changed using the [`.editorconfig`](./analyzer-diagnostics/index.md ## Map properties using user-implemented mappings See [user-implemented property conversions](./mapper.mdx#user-implemented-property-mappings). -The mapper will respect the `ref` keyword on the target parameter when using an existing target function, allowing the reference to be updated in the caller. + +A `void` method with a `ref` target parameter can be used to convert a property value in-place. +Mapperly respects the `ref` keyword on the target parameter when using an existing target function, +allowing the reference to be updated in the caller. + +When `AutoUserMappings` is enabled (the default), such methods are discovered and used automatically +for property mapping — no `[MapProperty(Use = ...)]` attribute is required: ```csharp - [Mapper(AllowNullPropertyAssignment = false, UseDeepCloning = true, RequiredMappingStrategy = RequiredMappingStrategy.Source)] - public static partial class UseUserMethodWithRef - { - [MapProperty(nameof(ArrayObject.IntArray), nameof(ArrayObject.IntArray), Use = nameof(MapArray))] // `Use` is required otherwise it will generate it's own - public static partial void Merge([MappingTarget] ArrayObject target, ArrayObject second); - - private static void MapArray([MappingTarget] ref int[] target, int[] second) => target = [.. target, .. second.Except(target)]; - } +[Mapper] +public static partial class CarMapper +{ + public static partial void Update([MappingTarget] CarDto target, Car source); + + // highlight-start + // automatically discovered and used for Optional => string property conversions + private static void MapOptional(Optional src, ref string target) + { + if (src.HasValue) + target = src.Value; + } + // highlight-end + + // generates: + // var targetRef = target.Name; + // MapOptional(source.Name, ref targetRef); + // target.Name = targetRef; +} +``` + +If a new-instance user method (i.e. a method that returns a value) also exists for the same type pair, +it takes priority over the `ref` method for property mapping. + +To use a `ref` method for a specific property explicitly, or when `AutoUserMappings` is disabled, use +`[MapProperty(Use = nameof(...))]`: + +```csharp +[Mapper(AllowNullPropertyAssignment = false, UseDeepCloning = true, RequiredMappingStrategy = RequiredMappingStrategy.Source)] +public static partial class UseUserMethodWithRef +{ + // highlight-start + [MapProperty(nameof(ArrayObject.IntArray), nameof(ArrayObject.IntArray), Use = nameof(MapArray))] + // highlight-end + public static partial void Merge([MappingTarget] ArrayObject target, ArrayObject second); + + private static void MapArray([MappingTarget] ref int[] target, int[] second) => target = [.. target, .. second.Except(target)]; +} ``` ## Generic user-implemented mapping methods diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs index 679dbb0c3f..2a9433b154 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/ObjectMemberMappingBodyBuilder.cs @@ -222,8 +222,10 @@ MemberMappingInfo memberMappingInfo // if the member is readonly // and the target and source path is readable, // we also try to create an existing target mapping + var mappingKey = memberMappingInfo.ToTypeMappingKey(); if ( !HasExistingTargetNamedMapping(ctx, memberMappingInfo) + && !ctx.BuilderContext.HasExistingTargetRefUserMapping(mappingKey) && ( targetMemberPath.Member is { CanSet: true, IsInitOnly: false } || !targetMemberPath.Path.All(op => op.CanGet) @@ -234,7 +236,7 @@ MemberMappingInfo memberMappingInfo return false; } - var existingTargetMapping = ctx.BuilderContext.FindOrBuildExistingTargetMapping(memberMappingInfo.ToTypeMappingKey()); + var existingTargetMapping = ctx.BuilderContext.FindOrBuildExistingTargetMapping(mappingKey); if (existingTargetMapping is null) return false; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 448ccef459..4d492a7b02 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -286,6 +286,31 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => return mapping; } + public bool HasExistingTargetRefUserMapping(TypeMappingKey mappingKey) + { + // For same-type mappings, direct assignment is always preferred. + // The user should use [MapProperty(Use = nameof(...))] to opt in explicitly. + if (SymbolEqualityComparer.Default.Equals(mappingKey.Source, mappingKey.Target)) + return false; + + var refMapping = + ExistingTargetMappingBuilder.Find(mappingKey) as UserImplementedExistingTargetMethodMapping + ?? ExistingTargetMappingBuilder.Find(mappingKey.NonNullable()) as UserImplementedExistingTargetMethodMapping; + + if (refMapping is not { IsRefTarget: true }) + return false; + + // If the ref mapping is explicitly declared as the default, it takes precedence over any new-instance mapping. + if (refMapping.Default == true) + return true; + + // Otherwise, if a new-instance user mapping already covers these types, prefer that. + if (FindMapping(mappingKey) is INewInstanceUserMapping || FindMapping(mappingKey.NonNullable()) is INewInstanceUserMapping) + return false; + + return true; + } + /// /// Tries to build an existing target instance mapping. /// If no mapping is possible for the provided types, diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs index 4ef478957c..98d740f921 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs @@ -29,6 +29,8 @@ bool isExternal public bool IsExternal { get; } = isExternal; + public bool IsRefTarget => targetParameter.RefKind == RefKind.Ref; + public IReadOnlyCollection AdditionalSourceParameters { get; } = method .Parameters.Where(p => diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/UseUserMethodWithRefAutoDetect.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/UseUserMethodWithRefAutoDetect.cs new file mode 100644 index 0000000000..636eec2d20 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/Mapper/UseUserMethodWithRefAutoDetect.cs @@ -0,0 +1,19 @@ +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.IntegrationTests.Models; + +namespace Riok.Mapperly.IntegrationTests.Mapper +{ + [Mapper] + public static partial class UseUserMethodWithRefAutoDetect + { + public static partial void Map([MappingTarget] ITestGenericValue target, ITestGenericValue> source); + + private static void MapOptional(Optional src, ref string target) + { + if (src.HasValue) + { + target = src.Value; + } + } + } +} diff --git a/test/Riok.Mapperly.IntegrationTests/UseUserMethodWithRefAutoDetectTest.cs b/test/Riok.Mapperly.IntegrationTests/UseUserMethodWithRefAutoDetectTest.cs new file mode 100644 index 0000000000..ab23f803d1 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/UseUserMethodWithRefAutoDetectTest.cs @@ -0,0 +1,24 @@ +using Riok.Mapperly.IntegrationTests.Mapper; +using Riok.Mapperly.IntegrationTests.Models; +using Shouldly; +using Xunit; + +namespace Riok.Mapperly.IntegrationTests +{ + public class UseUserMethodWithRefAutoDetectTest : BaseMapperTest + { + [Fact] + public void RunMappingWithAutoDetectedRefMethod() + { + var src = new TestGenericObject> { Value = new Optional("hello") }; + var target = new TestGenericObject(); + UseUserMethodWithRefAutoDetect.Map(target, src); + target.Value.ShouldBe("hello"); + } + + private class TestGenericObject : ITestGenericValue + { + public T Value { get; set; } = default!; + } + } +} diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs index 26c4343aaa..8937a23206 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs @@ -84,6 +84,118 @@ static void MapList([MappingTarget] this ref int[] dest, int[] src) { } return TestHelper.VerifyGenerator(source); } + [Fact] + public void ShouldAutoUseVoidMethodWithRefTargetParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public static partial void Update([MappingTarget] B target, A source); + private static void MapOptional(Optional src, ref string target) + { + if (src.HasValue) + target = src.Value; + } + """, + "public class A { public Optional Name { get; set; } }", + "public class B { public string Name { get; set; } }", + """ + public struct Optional + { + public Optional(T value) { Value = value; HasValue = true; } + public bool HasValue { get; } + public T Value { get; } + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + var targetRef = target.Name; + MapOptional(source.Name, ref targetRef); + target.Name = targetRef; + """ + ); + } + + [Fact] + public void ShouldPreferNewInstanceMappingOverRefMappingForPropertyMapping() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public static partial void Update([MappingTarget] B target, A source); + private static string FromOptional(Optional src) => src.HasValue ? src.Value : ""; + private static void MapOptional(Optional src, ref string target) + { + if (src.HasValue) + target = src.Value; + } + """, + "public class A { public Optional Name { get; set; } }", + "public class B { public string Name { get; set; } }", + """ + public struct Optional + { + public Optional(T value) { Value = value; HasValue = true; } + public bool HasValue { get; } + public T Value { get; } + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + target.Name = FromOptional(source.Name); + """ + ); + } + + [Fact] + public void ShouldPreferExplicitDefaultRefMappingOverNewInstanceMappingForPropertyMapping() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + public static partial void Update([MappingTarget] B target, A source); + private static string FromOptional(Optional src) => src.HasValue ? src.Value : ""; + [UserMapping(Default = true)] + private static void MapOptional(Optional src, ref string target) + { + if (src.HasValue) + target = src.Value; + } + """, + "public class A { public Optional Name { get; set; } }", + "public class B { public string Name { get; set; } }", + """ + public struct Optional + { + public Optional(T value) { Value = value; HasValue = true; } + public bool HasValue { get; } + public T Value { get; } + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + var targetRef = target.Name; + MapOptional(source.Name, ref targetRef); + target.Name = targetRef; + """ + ); + } + [Fact] public void WithExistingInstance() {