Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions docs/docs/configuration/user-implemented-methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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> => string property conversions
private static void MapOptional(Optional<string> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;

Expand Down
25 changes: 25 additions & 0 deletions src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// Tries to build an existing target instance mapping.
/// If no mapping is possible for the provided types,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ bool isExternal

public bool IsExternal { get; } = isExternal;

public bool IsRefTarget => targetParameter.RefKind == RefKind.Ref;

public IReadOnlyCollection<MethodParameter> AdditionalSourceParameters { get; } =
method
.Parameters.Where(p =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> target, ITestGenericValue<Optional<string>> source);

private static void MapOptional(Optional<string> src, ref string target)
{
if (src.HasValue)
{
target = src.Value;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Optional<string>> { Value = new Optional<string>("hello") };
var target = new TestGenericObject<string>();
UseUserMethodWithRefAutoDetect.Map(target, src);
target.Value.ShouldBe("hello");
}

private class TestGenericObject<T> : ITestGenericValue<T>
{
public T Value { get; set; } = default!;
}
}
}
112 changes: 112 additions & 0 deletions test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> src, ref string target)
{
if (src.HasValue)
target = src.Value;
}
""",
"public class A { public Optional<string> Name { get; set; } }",
"public class B { public string Name { get; set; } }",
"""
public struct Optional<T>
{
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<string> src) => src.HasValue ? src.Value : "";
private static void MapOptional(Optional<string> src, ref string target)
{
if (src.HasValue)
target = src.Value;
}
""",
"public class A { public Optional<string> Name { get; set; } }",
"public class B { public string Name { get; set; } }",
"""
public struct Optional<T>
{
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<string> src) => src.HasValue ? src.Value : "";
[UserMapping(Default = true)]
private static void MapOptional(Optional<string> src, ref string target)
{
if (src.HasValue)
target = src.Value;
}
""",
"public class A { public Optional<string> Name { get; set; } }",
"public class B { public string Name { get; set; } }",
"""
public struct Optional<T>
{
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()
{
Expand Down