feat: support [MappingTargetOriginalValue] parameter in user-defined mapping methods#2306
feat: support [MappingTargetOriginalValue] parameter in user-defined mapping methods#2306juliancarrivick wants to merge 1 commit into
Conversation
| public static bool TryBuild( | ||
| IMembersBuilderContext<IMapping> ctx, | ||
| MemberMappingInfo memberMappingInfo, | ||
| CodeStyle codeStyle, | ||
| [NotNullWhen(true)] out ISourceValue? sourceValue | ||
| [NotNullWhen(true)] out SourceValueBuilder.MappedSourceValue? result | ||
| ) |
There was a problem hiding this comment.
note: This function really belongs in SourceValueBuilder - that is the only consumer and it leads to the confusing MemberMappingBuilder.TryBuildContainerAssignment/TryBuildAssignment() -> SourceValueBuilder.TryBuildMappedSourceValue() -> MemberMappingBuilder.TryBuild(). I haven't refactored that here to avoid an even larger diff, but can do so if you like.
There was a problem hiding this comment.
Thanks for the note. I'd be happy to see that refactoring, but I'd prefer to keep it separate PR from this change. If you're willing to, feel free to submit a follow-up PR 😊
latonz
left a comment
There was a problem hiding this comment.
Thank you for this contribution! I added my feedback.
| public static bool TryBuild( | ||
| IMembersBuilderContext<IMapping> ctx, | ||
| MemberMappingInfo memberMappingInfo, | ||
| CodeStyle codeStyle, | ||
| [NotNullWhen(true)] out ISourceValue? sourceValue | ||
| [NotNullWhen(true)] out SourceValueBuilder.MappedSourceValue? result | ||
| ) |
There was a problem hiding this comment.
Thanks for the note. I'd be happy to see that refactoring, but I'd prefer to keep it separate PR from this change. If you're willing to, feel free to submit a follow-up PR 😊
|
The vulnerability scanning which fails the test GH Action job should be gone once you rebase onto main. |
|
Thanks for the review @latonz ! I'm currently on holiday but will address your comments after I'm back next week :) |
|
enjoy your time off! |
e5a4ba3 to
4e98d97
Compare
juliancarrivick
left a comment
There was a problem hiding this comment.
Alright, I think I've covered everything - let me know if you require any further changes (and build should pass this time).
Thanks!
|
|
||
| - Only one `[MappingTargetOriginalValue]` parameter is allowed per method. | ||
| - Only supported on user-implemented (non-`partial`) mapping helper methods. It cannot be used on generated `partial` mapper method declarations. | ||
| - When mapping to a new instance (no `[MappingTarget]`), the destination value is `default(T)` — the type's default — since there is no existing value to read. |
There was a problem hiding this comment.
This is for the case where the mapper creates an instance of the target itself e.g. in test/Riok.Mapperly.IntegrationTests/MappingTargetOriginalValueMapperTest.cs and there is no opportunity to put a setter.
[Fact]
public void MapToDtoShouldUseDefaultWhenOptionalEmpty()
{
var mapper = new MappingTargetOriginalValueMapper();
var source = new OptionalObject();
var dto = mapper.MapToDto(source);
dto.Name.ShouldBeNull();
}
I guess though this is slightly misleading because it won't receive default(T) it will receive the default constructed value on the DTO instead which might be something else.
In any case, I have removed the block as requested :)
| MethodParameter Source, | ||
| MethodParameter? Target, | ||
| MethodParameter? ReferenceHandler, | ||
| MethodParameter? OriginalValueParameter, |
| public class UserMethodMappingTargetOriginalValueTest | ||
| { | ||
| [Fact] | ||
| public Task BasicDestinationValueParameter() |
There was a problem hiding this comment.
Another missed update 😅 Did this and updated the generated files ✔️
latonz
left a comment
There was a problem hiding this comment.
Thanks for the updates, I added my feedback.
| else if (targetOriginalValueParameter is not null && param.Ordinal == targetOriginalValueParameter.Value.Ordinal) | ||
| yield return targetOriginalValueParameter.Value.WithArgument( | ||
| targetValue | ||
| ?? DefaultExpression(SyntaxFactoryHelper.FullyQualifiedIdentifier(targetOriginalValueParameter.Value.Type)) |
There was a problem hiding this comment.
When targetValue is absent, this fallback uses the original parameter symbol type. For generic user-implemented methods used in constructor/init-only mappings, that can emit an unsubstituted type parameter at the call site, e.g. FromOptional<int>(src.Age, default(T?)). Use the concrete target/original-value type for the fallback and add a test for generic [MappingTargetOriginalValue] with an init-only or constructor-mapped member.
There was a problem hiding this comment.
Oooh excellent catch. Updated so that GenericUserImplementedNewInstanceMethodMapping will inject the concrete type so the type at this call-site is correct ✅
| return ctx.SymbolAccessor.WrapOptionalMethodParameter(targetParameterSymbol); | ||
| } | ||
|
|
||
| private static MethodParameter? TryFindFirstTargetOriginalValueParameter( |
There was a problem hiding this comment.
This method name could be simplified to FindTargetOriginalValueParameter as all of these methods only use the first param and also don't include it in the name.
| var refHandlerParameterOrdinal = refHandlerParameter?.Ordinal ?? -1; | ||
| matchedParameters.AddParameter(refHandlerParameter); | ||
|
|
||
| var sourceParameter = FindSourceParameter(ctx, method, refHandlerParameter); |
There was a problem hiding this comment.
Shouldn't the source parameter be matched after the targetOriginalValueParameter? Otherwise signatures like FromOptional([MappingTargetOriginalValue] int? original, Optional source) won't work, do they?
There was a problem hiding this comment.
Ah yep, another good catch. I've fixed this and added a test case.
| return refHandlerParameter; | ||
| } | ||
|
|
||
| private readonly struct MatchedParameterOrdinalList |
There was a problem hiding this comment.
Does this really add any value? Wouldn't a simple HashSet<int> matchedParamOrdinals be easier to read and have better performance?
There was a problem hiding this comment.
I wouldn't expect it to have better performance actually since we're only dealing with O(3) and HashMap will have the hashing overhead. I thought I'd quantitatively check though:
➜ dotnet new console
➜ dotnet add package BenchmarkDotNet
➜ cat > Program.cs << 'EOF'
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<HashSetVsListBenchmark>();
[MemoryDiagnoser]
public class HashSetVsListBenchmark
{
private readonly List<int> _list = new() { 1, 2, 3 };
private readonly HashSet<int> _set = new() { 1, 2, 3 };
[Params(1, 2, 3, 99)]
public int Target;
[Benchmark(Baseline = true)]
public bool ListContains() => _list.Contains(Target);
[Benchmark]
public bool HashSetContains() => _set.Contains(Target);
}
EOF
➜ dotnet run -c Release
...
| Method | Target | Mean | Error | StdDev | Ratio | Allocated | Alloc Ratio |
|---------------- |------- |---------:|----------:|----------:|------:|----------:|------------:|
| ListContains | 1 | 1.304 ns | 0.0016 ns | 0.0013 ns | 1.00 | - | NA |
| HashSetContains | 1 | 2.197 ns | 0.0124 ns | 0.0097 ns | 1.68 | - | NA |
| | | | | | | | |
| ListContains | 2 | 1.816 ns | 0.0035 ns | 0.0031 ns | 1.00 | - | NA |
| HashSetContains | 2 | 2.206 ns | 0.0156 ns | 0.0130 ns | 1.21 | - | NA |
| | | | | | | | |
| ListContains | 3 | 1.985 ns | 0.0043 ns | 0.0033 ns | 1.00 | - | NA |
| HashSetContains | 3 | 2.209 ns | 0.0121 ns | 0.0113 ns | 1.11 | - | NA |
| | | | | | | | |
| ListContains | 99 | 2.272 ns | 0.0095 ns | 0.0084 ns | 1.00 | - | NA |
| HashSetContains | 99 | 2.216 ns | 0.0135 ns | 0.0120 ns | 0.98 | - | NA |
However, this isn't performance critical code, so HashMap will probably still be fast enough, given you'd prefer it for readability. I wanted to avoid repeating the p?.Ordinal ?? -1 logic but if you feel that's more readable than a separate helper I'm happy to defer to you here.
There was a problem hiding this comment.
Noticed on closer inspection that the !Contains() HashSet case is actually marginally faster so maybe this evens out in the wash, regardless, I've updated 👍
…mapping methods Adds MappingTargetOriginalValueAttribute, which marks a parameter in a user-implemented mapping method to receive the existing target member value. Useful for merge-style mappings that need to combine source and destination data rather than fully overwrite. Support for [MappingTarget] is not included as the common use-case there is to take the entire target object and this will end up being the exact same reference as the "original value". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4e98d97 to
40b6a78
Compare
Support [MappingTargetOriginalValue] parameter in user-defined mapping methods
Description
Following feedback in #2242, fixing #276 instead to cover the
Optionaluse-case.Here we add
MappingTargetOriginalValueAttribute, which marks a parameter in a user-implemented mapping method to receive the existing target member value. Useful for merge-style mappings that need to combine source and destination data rather than fully overwrite.Support for
[MappingTarget]is not included as the use-case there is to work with entire object references instead of members. In that case both the "target" and the "original" value will end up being the same reference lead to potential confusion.Thank you for considering this PR.
Checklist
MemberAssignmentMapping.TargetValueGetterinMemberMappingBuilder)