From a88699935ebf009d5e4db61e0100343818ac3cc0 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:03:57 +0100 Subject: [PATCH 01/32] feat: add ParameterScope and TypeMappingBuildContext.AdditionalParameters foundation --- .../Mappings/TypeMappingBuildContext.cs | 72 ++++++++++++-- .../Descriptors/ParameterScope.cs | 94 +++++++++++++++++++ src/Riok.Mapperly/Symbols/MethodParameter.cs | 5 + 3 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/Riok.Mapperly/Descriptors/ParameterScope.cs diff --git a/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs b/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs index ba9ef9934d..ecf3a51491 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs @@ -1,6 +1,8 @@ +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Emit.Syntax; using Riok.Mapperly.Helpers; +using Riok.Mapperly.Symbols; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace Riok.Mapperly.Descriptors.Mappings; @@ -13,21 +15,30 @@ public TypeMappingBuildContext( string source, string? referenceHandler, UniqueNameBuilder nameBuilder, - SyntaxFactoryHelper syntaxFactory + SyntaxFactoryHelper syntaxFactory, + IReadOnlyDictionary? additionalParameters = null ) - : this(IdentifierName(source), referenceHandler == null ? null : IdentifierName(referenceHandler), nameBuilder, syntaxFactory) { } + : this( + IdentifierName(source), + referenceHandler == null ? null : IdentifierName(referenceHandler), + nameBuilder, + syntaxFactory, + additionalParameters + ) { } private TypeMappingBuildContext( ExpressionSyntax source, ExpressionSyntax? referenceHandler, UniqueNameBuilder nameBuilder, - SyntaxFactoryHelper syntaxFactory + SyntaxFactoryHelper syntaxFactory, + IReadOnlyDictionary? additionalParameters = null ) { Source = source; ReferenceHandler = referenceHandler; NameBuilder = nameBuilder; SyntaxFactory = syntaxFactory; + AdditionalParameters = additionalParameters; } public UniqueNameBuilder NameBuilder { get; } @@ -38,7 +49,10 @@ SyntaxFactoryHelper syntaxFactory public SyntaxFactoryHelper SyntaxFactory { get; } - public TypeMappingBuildContext AddIndentation() => new(Source, ReferenceHandler, NameBuilder, SyntaxFactory.AddIndentation()); + public IReadOnlyDictionary? AdditionalParameters { get; } + + public TypeMappingBuildContext AddIndentation() => + new(Source, ReferenceHandler, NameBuilder, SyntaxFactory.AddIndentation(), AdditionalParameters); /// /// Creates a new scoped name builder, @@ -65,7 +79,13 @@ SyntaxFactoryHelper syntaxFactory { var scopedNameBuilder = NameBuilder.NewScope(); var scopedSourceName = scopedNameBuilder.New(sourceName); - var ctx = new TypeMappingBuildContext(sourceBuilder(scopedSourceName), ReferenceHandler, scopedNameBuilder, SyntaxFactory); + var ctx = new TypeMappingBuildContext( + sourceBuilder(scopedSourceName), + ReferenceHandler, + scopedNameBuilder, + SyntaxFactory, + AdditionalParameters + ); return (ctx, scopedSourceName); } @@ -75,9 +95,47 @@ SyntaxFactoryHelper syntaxFactory return (WithSource(IdentifierName(scopedSourceName)), scopedSourceName); } - public TypeMappingBuildContext WithSource(ExpressionSyntax source) => new(source, ReferenceHandler, NameBuilder, SyntaxFactory); + public TypeMappingBuildContext WithSource(ExpressionSyntax source) => + new(source, ReferenceHandler, NameBuilder, SyntaxFactory, AdditionalParameters); public TypeMappingBuildContext WithRefHandler(string refHandler) => WithRefHandler(IdentifierName(refHandler)); - public TypeMappingBuildContext WithRefHandler(ExpressionSyntax refHandler) => new(Source, refHandler, NameBuilder, SyntaxFactory); + public TypeMappingBuildContext WithRefHandler(ExpressionSyntax refHandler) => + new(Source, refHandler, NameBuilder, SyntaxFactory, AdditionalParameters); + + /// + /// Builds arguments for a user-implemented method call by matching each parameter + /// by ordinal to source, target, referenceHandler, or additional parameters. + /// + public MethodArgument?[] BuildArguments( + IMethodSymbol? method, + MethodParameter sourceParameter, + MethodParameter? referenceHandlerParameter, + MethodArgument? targetArgument = null + ) + { + if (method is null) + return [sourceParameter.WithArgument(Source), targetArgument, referenceHandlerParameter?.WithArgument(ReferenceHandler)]; + + return Arguments(Source, ReferenceHandler, AdditionalParameters).ToArray(); + + IEnumerable Arguments( + ExpressionSyntax? source, + ExpressionSyntax? refHandler, + IReadOnlyDictionary? additionalParams + ) + { + foreach (var param in method.Parameters) + { + if (param.Ordinal == sourceParameter.Ordinal) + yield return sourceParameter.WithArgument(source); + else if (targetArgument is not null && param.Ordinal == targetArgument.Value.Parameter.Ordinal) + yield return targetArgument.Value; + else if (referenceHandlerParameter is not null && param.Ordinal == referenceHandlerParameter.Value.Ordinal) + yield return referenceHandlerParameter.Value.WithArgument(refHandler); + else if (additionalParams?.TryGetValue(param.Name.TrimStart('@'), out var expr) == true) + yield return new MethodParameter(param, param.Type).WithArgument(expr); + } + } + } } diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs new file mode 100644 index 0000000000..9a93126388 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Symbols; + +namespace Riok.Mapperly.Descriptors; + +public class ParameterScope +{ + private static readonly IReadOnlyDictionary EmptyParameters = new Dictionary(); + + private readonly ParameterScope? _parent; + private readonly IReadOnlyDictionary _parameters; + private readonly HashSet? _usedParameters; + + public static readonly ParameterScope Empty = new([]); + + public ParameterScope(IReadOnlyCollection parameters) + { + if (parameters.Count == 0) + { + _parameters = EmptyParameters; + return; + } + + _parameters = parameters.ToDictionary(p => p.NormalizedName, p => p, StringComparer.OrdinalIgnoreCase); + _usedParameters = new(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Creates a child scope that delegates to the given parent. + /// Only the root scope (no parent) tracks and reports unused parameters. + /// + public ParameterScope(ParameterScope parent) + { + _parent = parent; + _parameters = parent._parameters; + } + + public bool IsRoot => _parent == null && _usedParameters != null; + + public bool IsEmpty => _parameters.Count == 0; + + public IReadOnlyDictionary Parameters => _parameters; + + /// + /// Checks if all requested additional parameters can be satisfied by this scope. + /// Matching is by name (case-insensitive). A parameter can be matched by multiple consumers. + /// + public bool TryMatchParameters(IReadOnlyCollection requested, out IReadOnlyList matched) + { + var result = new List(requested.Count); + foreach (var param in requested) + { + if (!_parameters.TryGetValue(param.NormalizedName, out var scopeParam)) + { + matched = []; + return false; + } + result.Add(scopeParam); + } + matched = result; + return true; + } + + /// + /// Checks whether all parameters of a method can be satisfied by this scope (by normalized name). + /// Returns true for parameterless methods. A null or empty scope can only satisfy parameterless methods. + /// + public static bool CanSatisfyParameters(ParameterScope? scope, IMethodSymbol method) => + method.Parameters.Length == 0 + || (scope is { IsEmpty: false } && method.Parameters.All(p => scope._parameters.ContainsKey(NormalizeName(p.Name)))); + + /// + /// Mark a parameter as having at least one consumer (idempotent). + /// Delegates to the root scope so usage tracking stays unified. + /// + public void MarkUsed(string name) + { + if (_parent != null) + { + _parent.MarkUsed(name); + return; + } + + _usedParameters?.Add(NormalizeName(name)); + } + + private static string NormalizeName(string name) => name.TrimStart('@'); + + /// + /// Returns parameter names that were never consumed by any consumer (for diagnostics). + /// + public IEnumerable GetUnusedParameterNames() => + _usedParameters is null ? [] : _parameters.Keys.Where(k => !_usedParameters.Contains(k)); +} diff --git a/src/Riok.Mapperly/Symbols/MethodParameter.cs b/src/Riok.Mapperly/Symbols/MethodParameter.cs index 660bc7aa51..cdfb6a0597 100644 --- a/src/Riok.Mapperly/Symbols/MethodParameter.cs +++ b/src/Riok.Mapperly/Symbols/MethodParameter.cs @@ -13,6 +13,11 @@ public readonly record struct MethodParameter(int Ordinal, string Name, ITypeSym public MethodParameter(IParameterSymbol symbol, ITypeSymbol parameterType) : this(symbol.Ordinal, symbol.ToDisplayString(_parameterNameFormat), parameterType, symbol.RefKind) { } + /// + /// The parameter name with the verbatim identifier prefix (@) removed. + /// + public string NormalizedName => Name.TrimStart('@'); + public MethodArgument WithArgument(ExpressionSyntax? argument) => new(this, argument ?? throw new ArgumentNullException(nameof(argument))); } From 34ce1bd3ed6c5f32416351356b513b536f7754f5 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:11:33 +0100 Subject: [PATCH 02/32] feat: forward additional parameters in MethodMapping.Build() and subclass invocations --- .../UserDefinedExistingTargetMethodMapping.cs | 4 +--- ...serImplementedExistingTargetMethodMapping.cs | 17 ++++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserDefinedExistingTargetMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserDefinedExistingTargetMethodMapping.cs index 62840c55f7..768967f7f1 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserDefinedExistingTargetMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserDefinedExistingTargetMethodMapping.cs @@ -46,9 +46,7 @@ public IEnumerable Build(TypeMappingBuildContext ctx, Expressio return ctx.SyntaxFactory.SingleStatement( ctx.SyntaxFactory.Invocation( MethodName, - SourceParameter.WithArgument(ctx.Source), - TargetParameter.WithArgument(target), - ReferenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) + ctx.BuildArguments(Method, SourceParameter, ReferenceHandlerParameter, TargetParameter.WithArgument(target)) ) ); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs index a613440abb..f5d86dbd0c 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs @@ -40,9 +40,12 @@ public override IEnumerable Build(TypeMappingBuildContext ctx, yield return ctx.SyntaxFactory.ExpressionStatement( ctx.SyntaxFactory.Invocation( receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name), - sourceParameter.WithArgument(ctx.Source), - targetParameter.WithArgument(IdentifierName(targetRefVarName)), - referenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) + ctx.BuildArguments( + Method, + sourceParameter, + referenceHandlerParameter, + targetParameter.WithArgument(IdentifierName(targetRefVarName)) + ) ) ); yield return ctx.SyntaxFactory.ExpressionStatement(Assignment(target, IdentifierName(targetRefVarName), false)); @@ -53,9 +56,7 @@ public override IEnumerable Build(TypeMappingBuildContext ctx, yield return ctx.SyntaxFactory.ExpressionStatement( ctx.SyntaxFactory.Invocation( receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name), - sourceParameter.WithArgument(ctx.Source), - targetParameter.WithArgument(target), - referenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) + ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target)) ) ); yield break; @@ -69,9 +70,7 @@ public override IEnumerable Build(TypeMappingBuildContext ctx, yield return ctx.SyntaxFactory.ExpressionStatement( ctx.SyntaxFactory.Invocation( methodExpr, - sourceParameter.WithArgument(ctx.Source), - targetParameter.WithArgument(target), - referenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) + ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter, targetParameter.WithArgument(target)) ) ); } From a8b638c9c83c005d1f9674d15a0f5a0a7211f2a6 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:17:32 +0100 Subject: [PATCH 03/32] feat: integrate ParameterScope into MembersMappingState and MappingBuilderContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always allow additional parameters in BuildUserImplementedMapping (remove allowAdditionalParameters parameter — was true at all call sites) --- .../BuilderContext/MembersMappingState.cs | 7 +++++-- .../MembersMappingStateBuilder.cs | 12 ++++++++++- .../Descriptors/MappingBuilderContext.cs | 21 +++++++++++++++++++ .../Mappings/IParameterizedMapping.cs | 11 ++++++++++ .../Descriptors/Mappings/MethodMapping.cs | 21 ++++++++++++------- .../UserImplementedMethodMapping.cs | 9 ++------ .../Descriptors/UserMethodMappingExtractor.cs | 2 +- 7 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 src/Riok.Mapperly/Descriptors/Mappings/IParameterizedMapping.cs diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs index 70190b979f..21822c9a55 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs @@ -29,7 +29,8 @@ internal class MembersMappingState( Dictionary> memberValueConfigsByRootTargetName, Dictionary> memberConfigsByRootTargetName, Dictionary> configuredTargetMembersByRootName, - HashSet ignoredSourceMemberNames + HashSet ignoredSourceMemberNames, + ParameterScope? parameterScope = null ) { private readonly Dictionary _aliasedSourceMembers = new(StringComparer.OrdinalIgnoreCase); @@ -49,6 +50,8 @@ HashSet ignoredSourceMemberNames /// private readonly HashSet _unmappedTargetMemberNames = unmappedTargetMemberNames; + public ParameterScope? ParameterScope => parameterScope; + public IReadOnlyCollection IgnoredSourceMemberNames => ignoredSourceMemberNames; /// @@ -210,8 +213,8 @@ private void SetSourceMemberMapped(SourceMemberPath sourcePath) _unmappedSourceMemberNames.Remove(sourceMember.Name); break; case SourceMemberType.AdditionalMappingMethodParameter: - // trim verbatim identifier prefix _unmappedAdditionalSourceMemberNames.Remove(sourceMember.Name.TrimStart('@')); + parameterScope?.MarkUsed(sourceMember.Name); break; } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index f8b53da05b..a63f8e226f 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -38,6 +38,7 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp var unmappedSourceMemberNames = GetSourceMemberNames(ctx, mapping); var additionalSourceMembers = GetAdditionalSourceMembers(ctx); var unmappedAdditionalSourceMemberNames = new HashSet(additionalSourceMembers.Keys, StringComparer.Ordinal); + var parameterScope = BuildParameterScope(ctx); var targetMembers = GetTargetMembers(ctx, mapping); // build ignored members @@ -66,10 +67,19 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp memberValueConfigsByRootTargetName, memberConfigsByRootTargetName, configuredTargetMembersByRootName.AsDictionary(), - ignoredSourceMemberNames + ignoredSourceMemberNames, + parameterScope ); } + private static ParameterScope BuildParameterScope(MappingBuilderContext ctx) + { + if (ctx.UserMapping is not MethodMapping { AdditionalSourceParameters.Count: > 0 } methodMapping) + return ParameterScope.Empty; + + return new ParameterScope(methodMapping.AdditionalSourceParameters); + } + private static IReadOnlyDictionary GetAdditionalSourceMembers(MappingBuilderContext ctx) { if (ctx.UserMapping is not MethodMapping { AdditionalSourceParameters.Count: > 0 } methodMapping) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index d72ea37f76..45c64aafb0 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -56,12 +56,31 @@ protected MappingBuilderContext( supportsDeepCloning: supportsDeepCloning ) { + // Wrap parent scope in a child (delegates MarkUsed upward), or initialize from user mapping. + // Only the root scope reports unused parameters in diagnostics. + ParameterScope = ctx.ParameterScope is { } parentScope ? new ParameterScope(parentScope) : BuildParameterScope(userMapping); if (ignoreDerivedTypes) { Configuration = Configuration with { DerivedTypes = [] }; } } + private static ParameterScope? BuildParameterScope(IUserMapping? userMapping) => + userMapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm + ? new ParameterScope(pm.AdditionalSourceParameters) + : null; + + private static void MarkAdditionalParametersUsed(ITypeMapping mapping, ParameterScope scope) + { + if (mapping is not IParameterizedMapping parameterized) + return; + + foreach (var param in parameterized.AdditionalSourceParameters) + { + scope.MarkUsed(param.Name); + } + } + public TypeMappingKey MappingKey { get; } public ITypeSymbol Source => MappingKey.Source; @@ -83,6 +102,8 @@ protected MappingBuilderContext( /// public virtual bool IsExpression => false; + public ParameterScope? ParameterScope { get; } + public InstanceConstructorFactory InstanceConstructors { get; } /// diff --git a/src/Riok.Mapperly/Descriptors/Mappings/IParameterizedMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/IParameterizedMapping.cs new file mode 100644 index 0000000000..0e1f06b77b --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/IParameterizedMapping.cs @@ -0,0 +1,11 @@ +using Riok.Mapperly.Symbols; + +namespace Riok.Mapperly.Descriptors.Mappings; + +/// +/// A mapping that accepts additional source parameters beyond the source object. +/// +public interface IParameterizedMapping +{ + IReadOnlyCollection AdditionalSourceParameters { get; } +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs index a3a7da7ded..1f94a5c7e4 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs @@ -15,7 +15,7 @@ namespace Riok.Mapperly.Descriptors.Mappings; /// Represents a mapping which is not a single expression but an entire method. /// [DebuggerDisplay("{GetType().Name}({SourceType} => {TargetType})")] -public abstract class MethodMapping : ITypeMapping +public abstract class MethodMapping : ITypeMapping, IParameterizedMapping { protected const string DefaultReferenceHandlerParameterName = "refHandler"; private const string DefaultSourceParameterName = "source"; @@ -81,19 +81,26 @@ ITypeSymbol targetType public virtual IEnumerable BuildAdditionalMappingKeys(TypeMappingConfiguration config) => []; public virtual ExpressionSyntax Build(TypeMappingBuildContext ctx) => - ctx.SyntaxFactory.Invocation( - MethodName, - SourceParameter.WithArgument(ctx.Source), - ReferenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) - ); + ctx.SyntaxFactory.Invocation(MethodName, ctx.BuildArguments(Method, SourceParameter, ReferenceHandlerParameter)); public virtual MethodDeclarationSyntax BuildMethod(SourceEmitterContext ctx) { + IReadOnlyDictionary? additionalParams = null; + if (AdditionalSourceParameters.Count > 0) + { + additionalParams = AdditionalSourceParameters.ToDictionary( + p => p.NormalizedName, + p => (ExpressionSyntax)IdentifierName(p.Name), + StringComparer.OrdinalIgnoreCase + ); + } + var typeMappingBuildContext = new TypeMappingBuildContext( SourceParameter.Name, ReferenceHandlerParameter?.Name, ctx.NameBuilder.NewScope(), - ctx.SyntaxFactory + ctx.SyntaxFactory, + additionalParams ); var parameters = BuildParameterList(); diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs index a1d41c2970..1cbe137db3 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs @@ -58,8 +58,7 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx) { return ctx.SyntaxFactory.Invocation( receiver == null ? IdentifierName(Method.Name) : MemberAccess(receiver, Method.Name), - sourceParameter.WithArgument(ctx.Source), - referenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) + ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter) ); } @@ -68,10 +67,6 @@ public override ExpressionSyntax Build(TypeMappingBuildContext ctx) receiver == null ? ThisExpression() : IdentifierName(receiver) ); var methodExpr = MemberAccess(ParenthesizedExpression(castedReceiver), Method.Name); - return ctx.SyntaxFactory.Invocation( - methodExpr, - sourceParameter.WithArgument(ctx.Source), - referenceHandlerParameter?.WithArgument(ctx.ReferenceHandler) - ); + return ctx.SyntaxFactory.Invocation(methodExpr, ctx.BuildArguments(Method, sourceParameter, referenceHandlerParameter)); } } diff --git a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs index cf2bb536d3..1b745c06a0 100644 --- a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs @@ -169,7 +169,7 @@ private static bool IsMappingMethodCandidate(SimpleMappingBuilderContext ctx, IM var userMappingConfig = GetUserMappingConfig(ctx, method, out var hasAttribute); var valid = !method.IsGenericMethod && (allowPartial || !method.IsPartialDefinition) && (!isStatic || method.IsStatic); - if (!valid || !UserMappingMethodParameterExtractor.BuildParameters(ctx, method, false, out var parameters)) + if (!valid || !UserMappingMethodParameterExtractor.BuildParameters(ctx, method, true, out var parameters)) { if (!hasAttribute) return null; From 8642f58e4c741ff6e04b24c154c16d0a749095d5 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:25:28 +0100 Subject: [PATCH 04/32] feat: support additional parameters in MapValue Use methods --- .../MemberMappingDiagnosticReporter.cs | 7 ++ .../MappingBodyBuilders/SourceValueBuilder.cs | 49 +++++++- .../Diagnostics/DiagnosticDescriptors.cs | 10 ++ ...MethodAdditionalParameterForwardingTest.cs | 107 ++++++++++++++++++ 4 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs index 39e54dc088..f46cb2d42f 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs @@ -56,8 +56,15 @@ bool requiredMembersNeedToBeMapped private static void AddUnmappedAdditionalSourceMembersDiagnostics(MappingBuilderContext ctx, MembersMappingState state) { + var scope = state.ParameterScope; + var unusedInScope = scope != null ? new HashSet(scope.GetUnusedParameterNames(), StringComparer.OrdinalIgnoreCase) : null; foreach (var name in state.UnmappedAdditionalSourceMemberNames) { + // Skip parameters that were consumed via ParameterScope (e.g. by MapValue Use methods) + // but not removed from the unmapped additional source members set. + if (unusedInScope != null && !unusedInScope.Contains(name)) + continue; + ctx.ReportDiagnostic(DiagnosticDescriptors.AdditionalParameterNotMapped, name, ctx.UserMapping!.Method.Name); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index a6f666e0a5..b77efba969 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Configuration; +using Riok.Mapperly.Configuration.MethodReferences; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.MemberMappings; @@ -158,22 +159,21 @@ private static bool TryBuildMethodProvidedSourceValue( { var methodReferenceConfiguration = memberMappingInfo.ValueConfiguration!.Use!; var targetSymbol = methodReferenceConfiguration.GetTargetType(ctx.BuilderContext); + var scope = ctx.BuilderContext.ParameterScope; var namedMethodCandidates = targetSymbol is null ? [] : ctx .BuilderContext.SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) .Where(m => - m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false, Parameters.Length: 0 } + m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false } + && ParameterScope.CanSatisfyParameters(scope, m) && ctx.BuilderContext.AttributeAccessor.IsMappingNameEqualTo(m, methodReferenceConfiguration.Name) ) .ToList(); if (namedMethodCandidates.Count == 0) { - ctx.BuilderContext.ReportDiagnostic( - DiagnosticDescriptors.MapValueReferencedMethodNotFound, - methodReferenceConfiguration.FullName - ); + ReportMethodNotFoundDiagnostic(ctx.BuilderContext, targetSymbol, methodReferenceConfiguration); sourceValue = null; return false; } @@ -204,7 +204,44 @@ private static bool TryBuildMethodProvidedSourceValue( return false; } - sourceValue = new MethodProvidedSourceValue(methodSymbol.Name, methodReferenceConfiguration.GetTargetName(ctx.BuilderContext)); + // Collect additional parameter names and mark them as used + var additionalParameterNames = methodSymbol.Parameters.Select(param => param.Name).ToList(); + + foreach (var additionalParameterName in additionalParameterNames) + { + scope?.MarkUsed(additionalParameterName); + } + + sourceValue = new MethodProvidedSourceValue( + methodSymbol.Name, + methodReferenceConfiguration.GetTargetName(ctx.BuilderContext), + additionalParameterNames + ); return true; } + + private static void ReportMethodNotFoundDiagnostic( + MappingBuilderContext builderCtx, + ITypeSymbol? targetSymbol, + IMethodReferenceConfiguration methodRef + ) + { + // Check if a method by name exists but with unsatisfiable parameters + if ( + targetSymbol is not null + && builderCtx + .SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) + .Any(m => + m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false, Parameters.Length: > 0 } + && builderCtx.AttributeAccessor.IsMappingNameEqualTo(m, methodRef.Name) + ) + ) + { + builderCtx.ReportDiagnostic(DiagnosticDescriptors.MapValueMethodParametersUnsatisfied, methodRef.FullName); + } + else + { + builderCtx.ReportDiagnostic(DiagnosticDescriptors.MapValueReferencedMethodNotFound, methodRef.FullName); + } + } } diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index f5c49ccc3a..d41966365e 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -818,6 +818,16 @@ public static class DiagnosticDescriptors helpLinkUri: BuildHelpUri("RMG096") ); + public static readonly DiagnosticDescriptor MapValueMethodParametersUnsatisfied = new( + "RMG097", + "MapValue Use method parameters cannot be satisfied", + "The method {0} referenced by MapValue has parameters that cannot be matched from the mapping's additional parameters", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true, + helpLinkUri: BuildHelpUri("RMG097") + ); + private static string BuildHelpUri(string id) { #if ENV_NEXT diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs new file mode 100644 index 0000000000..a58be0d8fc --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -0,0 +1,107 @@ +using Riok.Mapperly.Diagnostics; + +namespace Riok.Mapperly.Tests.Mapping; + +public class UserMethodAdditionalParameterForwardingTest +{ + [Fact] + public void MapValueUseMethodWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetValue))] + partial B Map(A src, int ctx); + private int GetValue(int ctx) => ctx * 2; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + target.IntValue = GetValue(ctx); + return target; + """ + ); + } + + [Fact] + public void MapValueUseMethodWithMultipleAdditionalParameters() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(Combine))] + partial B Map(A src, int first, int second); + private int Combine(int first, int second) => first + second; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + target.IntValue = Combine(first, second); + return target; + """ + ); + } + + [Fact] + public void MapValueUseMethodWithZeroParamsStillWorks() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetDefault))] + partial B Map(A src); + private int GetDefault() => 42; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + target.IntValue = GetDefault(); + return target; + """ + ); + } + + [Fact] + public void MapValueUseMethodWithUnsatisfiableParametersShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetValue))] + partial B Map(A src); + private int GetValue(int ctx) => ctx * 2; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.MapValueMethodParametersUnsatisfied, + "The method GetValue referenced by MapValue has parameters that cannot be matched from the mapping's additional parameters" + ) + .HaveAssertedAllDiagnostics(); + } +} From 0e08bdf63cc0e1fa44a96e60764aa0dbdf456aa6 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:34:33 +0100 Subject: [PATCH 05/32] feat: prefer user-defined nested mappings with matching additional parameters --- .../Descriptors/MappingBuilderContext.cs | 20 ++++- .../MappingBuilders/MappingBuilder.cs | 3 + .../Descriptors/MappingCollection.cs | 82 +++++++++-------- .../SourceValue/MethodProvidedSourceValue.cs | 17 +++- ...MethodAdditionalParameterForwardingTest.cs | 89 +++++++++++++++++++ 5 files changed, 165 insertions(+), 46 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 45c64aafb0..bd605d3965 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -70,15 +70,21 @@ protected MappingBuilderContext( ? new ParameterScope(pm.AdditionalSourceParameters) : null; - private static void MarkAdditionalParametersUsed(ITypeMapping mapping, ParameterScope scope) + private INewInstanceMapping? FindParameterizedUserMapping(TypeMappingKey key) { + if (ParameterScope is null or { IsEmpty: true }) + return null; + + var mapping = MappingBuilder.FindUserMappingWithParameters(key, ParameterScope); if (mapping is not IParameterizedMapping parameterized) - return; + return null; foreach (var param in parameterized.AdditionalSourceParameters) { - scope.MarkUsed(param.Name); + ParameterScope.MarkUsed(param.Name); } + + return mapping; } public TypeMappingKey MappingKey { get; } @@ -189,7 +195,8 @@ private static void MarkAdditionalParametersUsed(ITypeMapping mapping, Parameter Location? diagnosticLocation = null ) { - return FindMapping(mappingKey) + return FindParameterizedUserMapping(mappingKey) + ?? FindMapping(mappingKey) ?? FindMapping(mappingKey.TargetNonNullable()) ?? BuildMapping(mappingKey, options, diagnosticLocation); } @@ -211,6 +218,11 @@ private static void MarkAdditionalParametersUsed(ITypeMapping mapping, Parameter Location? diagnosticLocation = null ) { + // Check parameterized user mappings first to ensure MarkUsed is called. + // Skip for expression contexts — expressions need the inlining path, not method calls. + if (!IsExpression && FindParameterizedUserMapping(key) is { } parameterizedMapping) + return parameterizedMapping; + if (FindMapping(key) is INewInstanceUserMapping mapping) return mapping; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs index 0ca9844a1e..a59e40e1c5 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs @@ -44,6 +44,9 @@ public class MappingBuilder(MappingCollection mappings, MapperDeclaration mapper public INewInstanceMapping? Find(TypeMappingKey mapping) => mappings.FindNewInstanceMapping(mapping); + public INewInstanceMapping? FindUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) => + mappings.FindNewInstanceUserMappingWithParameters(key, scope); + public INewInstanceMapping? FindOrResolveNamed(SimpleMappingBuilderContext ctx, string name, out bool ambiguousName) { if (!ctx.Configuration.Mapper.AutoUserMappings && _resolvedMappingNames.Add(name)) diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 9eb169e648..9f180b28f2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -57,12 +57,16 @@ public class MappingCollection /// public IEnumerable UsedDuplicatedNonDefaultNonReferencedUserMappings => - _newInstanceMappings - .UsedDuplicatedNonDefaultNonReferencedUserMappings.Cast() + Enumerable + .Empty() + .Concat(_newInstanceMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings) .Concat(_existingTargetMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings); public INewInstanceMapping? FindNewInstanceMapping(TypeMappingKey mappingKey) => _newInstanceMappings.Find(mappingKey); + public INewInstanceMapping? FindNewInstanceUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) => + _newInstanceMappings.FindUserMappingWithParameters(key, scope); + public INewInstanceUserMapping? FindNewInstanceUserMapping(IMethodSymbol method) => _newInstanceMappings.FindUserMapping(method); public INewInstanceMapping? FindNamedNewInstanceMapping(string name, out bool ambiguousName) => @@ -172,6 +176,12 @@ private class MappingCollectionInstance /// private readonly Dictionary _userMappingsByMethod = new(SymbolEqualityComparer.Default); + /// + /// All user mappings registered in this instance. + /// Used as the canonical source for parameterized and diagnostic queries. + /// + private readonly List _userMappings = []; + /// /// Named mappings by their names. /// @@ -187,12 +197,6 @@ private class MappingCollectionInstance /// private readonly HashSet _explicitDefaultMappingKeys = []; - /// - /// Contains the duplicated user implemented mappings - /// for type pairs with no explicit default mapping. - /// - private readonly ListDictionary _duplicatedNonDefaultUserMappings = new(); - /// /// All mapping keys for which was called and returned a non-null result. /// @@ -211,12 +215,18 @@ private class MappingCollectionInstance /// public IEnumerable UserMappings => _userMappingsByMethod.Values; - /// - /// - /// Includes only mappings for type-pairs which are actually in use. - /// + /// + /// Returns user mappings that are duplicates (same type pair, no explicit default) + /// for type-pairs which are actually in use and not referenced by name. + /// Within each group, the first mapping (which won the _defaultMappings race) is excluded. + /// public IEnumerable UsedDuplicatedNonDefaultNonReferencedUserMappings => - _usedMappingKeys.SelectMany(_duplicatedNonDefaultUserMappings.GetOrEmpty).Where(x => !_referencedNamedMappings.Contains(x)); + _userMappings + .Where(m => !m.IsExternal && !m.Default.HasValue) + .GroupBy(m => new TypeMappingKey(m)) + .Where(g => g.Count() > 1 && _usedMappingKeys.Contains(g.Key) && !_explicitDefaultMappingKeys.Contains(g.Key)) + .SelectMany(g => g.Skip(1)) + .Where(m => !_referencedNamedMappings.Contains(m)); public TUserMapping? FindUserMapping(IMethodSymbol method) => _userMappingsByMethod.GetValueOrDefault(method); @@ -242,6 +252,24 @@ private class MappingCollectionInstance return mapping; } + public TUserMapping? FindUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) + { + foreach (var mapping in _userMappings) + { + if ( + mapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm + && SymbolEqualityComparer.IncludeNullability.Equals(key.Source, mapping.SourceType) + && SymbolEqualityComparer.IncludeNullability.Equals(key.Target, mapping.TargetType) + && scope.TryMatchParameters(pm.AdditionalSourceParameters, out _) + ) + { + return mapping; + } + } + + return default; + } + public void AddNamedUserMapping(string? name, TUserMapping mapping) { var isNewUserMappingMethod = _userMappingsByMethod.TryAdd(mapping.Method, mapping); @@ -274,6 +302,7 @@ public MappingCollectionAddResult TryAddAsDefault(T mapping, TypeMappingConfigur public MappingCollectionAddResult AddUserMapping(TUserMapping mapping, bool? isDefault, string? name) { AddNamedUserMapping(name, mapping); + _userMappings.Add(mapping); return isDefault switch { @@ -287,41 +316,16 @@ public MappingCollectionAddResult AddUserMapping(TUserMapping mapping, bool? isD // no default value specified // add it if none exists yet - null => TryAddUserMappingAsDefault(mapping), + null => TryAddAsDefault(mapping, TypeMappingConfiguration.Default), }; } - private MappingCollectionAddResult TryAddUserMappingAsDefault(TUserMapping mapping) - { - var addResult = TryAddAsDefault(mapping, TypeMappingConfiguration.Default); - var mappingKey = new TypeMappingKey(mapping); - - // the mapping was not added due to it being a duplicate, - // there is no default mapping declared (yet) - // and no duplicate is registered yet - // then store this as duplicate - // this is needed to report a diagnostic if multiple non-default mappings - // are registered for the same type-pair without any default mapping. - if ( - addResult == MappingCollectionAddResult.NotAddedDuplicated - && !mapping.IsExternal - && !mapping.Default.HasValue - && !_explicitDefaultMappingKeys.Contains(mappingKey) - ) - { - _duplicatedNonDefaultUserMappings.Add(mappingKey, mapping); - } - - return addResult; - } - private MappingCollectionAddResult AddDefaultUserMapping(T mapping) { var mappingKey = new TypeMappingKey(mapping); if (!_explicitDefaultMappingKeys.Add(mappingKey)) return MappingCollectionAddResult.NotAddedDuplicated; - _duplicatedNonDefaultUserMappings.Remove(mappingKey); _defaultMappings[mappingKey] = mapping; AddAdditionalMappings(mapping, TypeMappingConfiguration.Default); return MappingCollectionAddResult.Added; diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MethodProvidedSourceValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MethodProvidedSourceValue.cs index f0ba80c10e..1e21d96549 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MethodProvidedSourceValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MethodProvidedSourceValue.cs @@ -7,8 +7,19 @@ namespace Riok.Mapperly.Descriptors.Mappings.MemberMappings.SourceValue; /// /// A source value which is provided by a method. /// -public class MethodProvidedSourceValue(string methodName, string? targetType) : ISourceValue +public class MethodProvidedSourceValue(string methodName, string? targetType, IReadOnlyList additionalParameterNames) : ISourceValue { - public ExpressionSyntax Build(TypeMappingBuildContext ctx) => - ctx.SyntaxFactory.Invocation(targetType == null ? IdentifierName(methodName) : MemberAccess(targetType, methodName)); + public ExpressionSyntax Build(TypeMappingBuildContext ctx) + { + ExpressionSyntax memberAccess = targetType == null ? IdentifierName(methodName) : MemberAccess(targetType, methodName); + + if (additionalParameterNames.Count == 0 || ctx.AdditionalParameters is null) + return ctx.SyntaxFactory.Invocation(memberAccess); + + var arguments = additionalParameterNames + .Select(name => Argument(ctx.AdditionalParameters.TryGetValue(name, out var expr) ? expr : IdentifierName(name))) + .ToArray(); + + return ctx.SyntaxFactory.Invocation(memberAccess, arguments); + } } diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index a58be0d8fc..9069ed0082 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -82,6 +82,95 @@ public void MapValueUseMethodWithZeroParamsStillWorks() ); } + [Fact] + public void NestedMappingWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + private partial BNested MapNested(ANested src, int ctx); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapNested(src.Nested, ctx); + return target; + """ + ); + } + + [Fact] + public void NestedMappingFallsBackToParameterlessWhenNoMatchingUserMethod() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapToBNested(src.Nested); + return target; + """ + ) + .HaveDiagnostic(DiagnosticDescriptors.AdditionalParameterNotMapped) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void ParameterUsedByBothPropertyAndNestedMapping() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("CtxValue", Use = nameof(GetCtx))] + partial B Map(A src, int ctx); + private int GetCtx(int ctx) => ctx; + private partial BNested MapNested(ANested src, int ctx); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } public int CtxValue { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapNested(src.Nested, ctx); + target.CtxValue = GetCtx(ctx); + return target; + """ + ) + .HaveAssertedAllDiagnostics(); + } + [Fact] public void MapValueUseMethodWithUnsatisfiableParametersShouldDiagnostic() { From 9970a90ebdba577977091130795018ba96caa7e0 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 11:43:34 +0100 Subject: [PATCH 06/32] feat: forward additional parameters through UseNamedMappingBuilder --- .../MappingBuilders/UseNamedMappingBuilder.cs | 26 +++++++ ...rImplementedExistingTargetMethodMapping.cs | 12 ++- .../UserImplementedMethodMapping.cs | 11 ++- .../Diagnostics/DiagnosticDescriptors.cs | 9 +++ ...MethodAdditionalParameterForwardingTest.cs | 73 +++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs index 262e1d5018..ba3c1250e5 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs @@ -20,6 +20,9 @@ public static class UseNamedMappingBuilder return null; } + if (!ValidateAndMarkAdditionalParameters(ctx, mapping, ctx.MappingKey.Configuration.UseNamedMapping)) + return null; + var differentSourceType = !SymbolEqualityComparer.IncludeNullability.Equals(ctx.Source, mapping.SourceType); var differentTargetType = !SymbolEqualityComparer.IncludeNullability.Equals(ctx.Target, mapping.TargetType); @@ -49,6 +52,9 @@ public static class UseNamedMappingBuilder if (existingTargetMapping is null) return null; + if (!ValidateAndMarkAdditionalParameters(ctx, existingTargetMapping, useNamedMapping)) + return null; + var source = ctx.Source; var target = ctx.Target; @@ -138,6 +144,26 @@ IExistingTargetMapping existingTargetMapping return new CompositeMapping(outputMapping, mapping); } + private static bool ValidateAndMarkAdditionalParameters(MappingBuilderContext ctx, ITypeMapping mapping, string mappingName) + { + if (mapping is not IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm) + return true; + + var scope = ctx.ParameterScope; + if (scope is null || scope.IsEmpty || !scope.TryMatchParameters(pm.AdditionalSourceParameters, out var matched)) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, mappingName); + return false; + } + + foreach (var param in matched) + { + scope.MarkUsed(param.Name); + } + + return true; + } + private static INewInstanceMapping? TryMapSource(MappingBuilderContext ctx, INewInstanceMapping mapping) { // report if the source can't be assigned to the mapping source type diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs index f5d86dbd0c..ea17eb443a 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedExistingTargetMethodMapping.cs @@ -18,7 +18,7 @@ public class UserImplementedExistingTargetMethodMapping( MethodParameter targetParameter, MethodParameter? referenceHandlerParameter, bool isExternal -) : ExistingTargetMapping(method.Parameters[0].Type, targetParameter.Type), IExistingTargetUserMapping +) : ExistingTargetMapping(method.Parameters[0].Type, targetParameter.Type), IExistingTargetUserMapping, IParameterizedMapping { public IMethodSymbol Method { get; } = method; @@ -26,6 +26,16 @@ bool isExternal public bool IsExternal { get; } = isExternal; + public IReadOnlyCollection AdditionalSourceParameters { get; } = + method + .Parameters.Where(p => + p.Ordinal != sourceParameter.Ordinal + && p.Ordinal != targetParameter.Ordinal + && (referenceHandlerParameter is null || p.Ordinal != referenceHandlerParameter.Value.Ordinal) + ) + .Select(p => new MethodParameter(p, p.Type)) + .ToList(); + public override IEnumerable Build(TypeMappingBuildContext ctx, ExpressionSyntax target) { // if the user implemented method is on an interface, diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs index 1cbe137db3..1735fe36fc 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs @@ -19,7 +19,7 @@ public class UserImplementedMethodMapping( MethodParameter? referenceHandlerParameter, bool isExternal, UserImplementedMethodMapping.TargetNullability targetNullability -) : NewInstanceMapping(sourceParameter.Type, targetType), INewInstanceUserMapping +) : NewInstanceMapping(sourceParameter.Type, targetType), INewInstanceUserMapping, IParameterizedMapping { public enum TargetNullability { @@ -34,6 +34,15 @@ public enum TargetNullability public bool IsExternal { get; } = isExternal; + public IReadOnlyCollection AdditionalSourceParameters { get; } = + method + .Parameters.Where(p => + p.Ordinal != sourceParameter.Ordinal + && (referenceHandlerParameter is null || p.Ordinal != referenceHandlerParameter.Value.Ordinal) + ) + .Select(p => new MethodParameter(p, p.Type)) + .ToList(); + public override IEnumerable BuildAdditionalMappingKeys(TypeMappingConfiguration config) { var keys = base.BuildAdditionalMappingKeys(config); diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index d41966365e..4bd7961698 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -828,6 +828,15 @@ public static class DiagnosticDescriptors helpLinkUri: BuildHelpUri("RMG097") ); + public static readonly DiagnosticDescriptor NamedMappingParametersUnsatisfied = new( + "RMG097", + "Named mapping additional parameters cannot be satisfied", + "The named mapping {0} has additional parameters that cannot be matched from the caller's scope", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); + private static string BuildHelpUri(string id) { #if ENV_NEXT diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index 9069ed0082..b0ec303a7a 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -193,4 +193,77 @@ public void MapValueUseMethodWithUnsatisfiableParametersShouldDiagnostic() ) .HaveAssertedAllDiagnostics(); } + + [Fact] + public void MapPropertyUseWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + partial B Map(A src, int multiplier); + private partial int Transform(int value, int multiplier); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Result = Transform(src.Value, multiplier); + return target; + """ + ); + } + + [Fact] + public void MapPropertyUseWithMultipleAdditionalParameters() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + partial B Map(A src, int multiplier, int offset); + private partial int Transform(int value, int multiplier, int offset); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Result = Transform(src.Value, multiplier, offset); + return target; + """ + ); + } + + [Fact] + public void MapPropertyUseWithUnsatisfiableParametersShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + partial B Map(A src); + private partial int Transform(int value, int multiplier); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NamedMappingParametersUnsatisfied, + "The named mapping Transform has additional parameters that cannot be matched from the caller's scope" + ) + .HaveAssertedAllDiagnostics(); + } } From 6564a957e63b0af7b4a7896d4b49e626ffbaed99 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 12:48:17 +0100 Subject: [PATCH 07/32] feat: support additional parameters in queryable projection inlining --- .../InlineExpressionMappingBuilder.cs | 2 +- .../Mapping/QueryableProjectionTest.cs | 31 +++++++++++++++++++ ...thAdditionalParameter#Mapper.g.verified.cs | 20 ++++++++++++ ...dWithAdditionalParams#Mapper.g.verified.cs | 16 ++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ClassToClassWithAdditionalParameter#Mapper.g.verified.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithUserImplementedMethodWithAdditionalParams#Mapper.g.verified.cs diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs index 9490423bc1..d92f975020 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs @@ -61,7 +61,7 @@ ITypeSymbol targetType var methodSyntax = methodSyntaxRef.GetSyntax(); - if (methodSyntax is not MethodDeclarationSyntax { ParameterList.Parameters: [var sourceParameter] } methodDeclaration) + if (methodSyntax is not MethodDeclarationSyntax { ParameterList.Parameters: [var sourceParameter, ..] } methodDeclaration) { return null; } diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs index 463db8c487..4980b6f862 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs @@ -225,6 +225,37 @@ public void WithReferenceHandlingShouldDiagnostic() .HaveAssertedAllDiagnostics(); } + [Fact] + public Task ClassToClassWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source, int currentUserId); + """, + """ + class A { public string Name { get; set; } } + class B { public string Name { get; set; } public int CurrentUserId { get; set; } } + """ + ); + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public Task ProjectionWithUserImplementedMethodWithAdditionalParams() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source, int currentUserId); + private B MapToB(A source, int currentUserId) => new B { Name = source.Name, CurrentUserId = currentUserId }; + """, + """ + class A { public string Name { get; set; } } + class B { public string Name { get; set; } public int CurrentUserId { get; set; } } + """ + ); + return TestHelper.VerifyGenerator(source); + } + [Fact] public async Task TopLevelUserImplemented() { diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ClassToClassWithAdditionalParameter#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ClassToClassWithAdditionalParameter#Mapper.g.verified.cs new file mode 100644 index 0000000000..48ee867058 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ClassToClassWithAdditionalParameter#Mapper.g.verified.cs @@ -0,0 +1,20 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source, int currentUserId) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Name = x.Name, + CurrentUserId = currentUserId, + } + ); +#nullable enable + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithUserImplementedMethodWithAdditionalParams#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithUserImplementedMethodWithAdditionalParams#Mapper.g.verified.cs new file mode 100644 index 0000000000..afebfeeb64 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithUserImplementedMethodWithAdditionalParams#Mapper.g.verified.cs @@ -0,0 +1,16 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source, int currentUserId) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B { Name = x.Name, CurrentUserId = currentUserId } + ); +#nullable enable + } +} \ No newline at end of file From ca57b9ac31edf6d091d3b7a005223f33864e4c47 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 12:54:30 +0100 Subject: [PATCH 08/32] feat: support additional parameters with MappingTarget existing target mappings --- .../Descriptors/InlineExpressionRewriter.cs | 2 +- .../MembersMappingStateBuilder.cs | 21 +++--- ...UserImplementedInlinedExpressionMapping.cs | 13 ++++ ...MethodAdditionalParameterForwardingTest.cs | 75 +++++++++++++++++++ 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionRewriter.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionRewriter.cs index f4375091b3..244cb120ee 100644 --- a/src/Riok.Mapperly/Descriptors/InlineExpressionRewriter.cs +++ b/src/Riok.Mapperly/Descriptors/InlineExpressionRewriter.cs @@ -166,7 +166,7 @@ public override SyntaxNode VisitTypeOfExpression(TypeOfExpressionSyntax node) _ => (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!, }; - if (node.ArgumentList.Arguments.Count == 1 && mappingResolver.Invoke(methodSymbol) is { } mapping) + if (node.ArgumentList.Arguments.Count >= 1 && mappingResolver.Invoke(methodSymbol) is { } mapping) { var annotation = new SyntaxAnnotation(SyntaxAnnotationKindMapperInvocation); _mappingInvocations.Add(annotation, mapping); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index a63f8e226f..99467595bc 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -72,21 +72,22 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp ); } - private static ParameterScope BuildParameterScope(MappingBuilderContext ctx) + private static (IReadOnlyDictionary Members, ParameterScope Scope) BuildAdditionalSourceMembersAndScope( + MappingBuilderContext ctx + ) { - if (ctx.UserMapping is not MethodMapping { AdditionalSourceParameters.Count: > 0 } methodMapping) - return ParameterScope.Empty; + // The copy-constructor provides the scope: inherited scopes are wrapped as child scopes, + // while own scopes (from BuildParameterScope) remain root scopes. + if (ctx.ParameterScope is { IsEmpty: false } scope) + return (BuildSourceMembersFromParameters(scope.Parameters.Values), scope); - return new ParameterScope(methodMapping.AdditionalSourceParameters); + return (_emptyAdditionalSourceMembers, ParameterScope.Empty); } - private static IReadOnlyDictionary GetAdditionalSourceMembers(MappingBuilderContext ctx) + private static IReadOnlyDictionary BuildSourceMembersFromParameters(IEnumerable parameters) { - if (ctx.UserMapping is not MethodMapping { AdditionalSourceParameters.Count: > 0 } methodMapping) - return _emptyAdditionalSourceMembers; - - return methodMapping.AdditionalSourceParameters.ToDictionary( - x => x.Name.TrimStart('@'), // trim verbatim identifier prefix + return parameters.ToDictionary( + x => x.NormalizedName, x => new ParameterSourceMember(x), StringComparer.OrdinalIgnoreCase ); diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs index 4e638e78ca..72d717aa23 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs @@ -119,6 +119,19 @@ public override SyntaxNode VisitParenthesizedLambdaExpression(ParenthesizedLambd return ctx.Source.WithTriviaFrom(node); } + // replace additional parameters with their corresponding expressions from the context + // Note: ctx.AdditionalParameters uses case-insensitive keys (for descriptor-phase matching), + // but syntax replacement must be case-sensitive to avoid replacing unrelated identifiers + // (e.g., property name CurrentUserId vs parameter currentUserId). + if (ctx.AdditionalParameters != null) + { + foreach (var (key, value) in ctx.AdditionalParameters) + { + if (string.Equals(key, node.Identifier.Text, StringComparison.Ordinal)) + return value.WithTriviaFrom(node); + } + } + return base.VisitIdentifierName(node); } diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index b0ec303a7a..c45331617c 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -266,4 +266,79 @@ public void MapPropertyUseWithUnsatisfiableParametersShouldDiagnostic() ) .HaveAssertedAllDiagnostics(); } + + [Fact] + public void ExistingTargetWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial void Update(A src, [MappingTarget] B target, int ctx); + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int Ctx { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + target.StringValue = src.StringValue; + target.Ctx = ctx; + """ + ); + } + + [Fact] + public void ExistingTargetWithNestedMappingForwarding() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial void Update(A src, [MappingTarget] B target, int ctx); + private partial BNested MapNested(ANested src, int ctx); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + target.Nested = MapNested(src.Nested, ctx); + """ + ); + } + + [Fact] + public void ExistingTargetWithMapValueUse() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetValue))] + partial void Update(A src, [MappingTarget] B target, int ctx); + private int GetValue(int ctx) => ctx * 2; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + target.StringValue = src.StringValue; + target.IntValue = GetValue(ctx); + """ + ); + } } From 5d1f751111215f16f34f37cabd33363a6a0bb0ea Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 13:01:40 +0100 Subject: [PATCH 09/32] test: add edge case tests for additional parameter forwarding --- ...MethodAdditionalParameterForwardingTest.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index c45331617c..f1a098c35c 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -341,4 +341,273 @@ public void ExistingTargetWithMapValueUse() """ ); } + + [Fact] + public void MultipleUseMethodsConsumingSameParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetDouble))] + [MapValue("OtherValue", Use = nameof(GetTriple))] + partial B Map(A src, int ctx); + private int GetDouble(int ctx) => ctx * 2; + private int GetTriple(int ctx) => ctx * 3; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } public int OtherValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + target.IntValue = GetDouble(ctx); + target.OtherValue = GetTriple(ctx); + return target; + """ + ); + } + + [Fact] + public void ParameterMappedToPropertyAndConsumedByNestedMapping() + { + // The parameter 'ctx' is mapped to the target property B.Ctx directly by name, + // AND also forwarded to a nested mapping method MapNested. + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + private partial BNested MapNested(ANested src, int ctx); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } public int Ctx { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapNested(src.Nested, ctx); + target.Ctx = ctx; + return target; + """ + ); + } + + [Fact] + public void ReferenceHandlingWithAdditionalParameters() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A src, int ctx);", + TestSourceBuilderOptions.WithReferenceHandling, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int Ctx { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var refHandler = new global::Riok.Mapperly.Abstractions.ReferenceHandling.PreserveReferenceHandler(); + if (refHandler.TryGetReference(src, out var existingTargetReference)) + return existingTargetReference; + var target = new global::B(); + refHandler.SetReference(src, target); + target.StringValue = src.StringValue; + target.Ctx = ctx; + return target; + """ + ); + } + + [Fact] + public void DeepNestingForwardsParametersThroughMultipleLevels() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial C Map(A src, int ctx); + private partial CInner MapInner(AInner src, int ctx); + private partial CDeep MapDeep(ADeep src, int ctx); + """, + """ + class A { public AInner Inner { get; set; } } + class C { public CInner Inner { get; set; } } + class AInner { public ADeep Deep { get; set; } } + class CInner { public CDeep Deep { get; set; } public int Ctx { get; set; } } + class ADeep { public int Value { get; set; } } + class CDeep { public int Value { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::C(); + target.Inner = MapInner(src.Inner, ctx); + return target; + """ + ) + .HaveMethodBody( + "MapInner", + """ + var target = new global::CInner(); + target.Deep = MapDeep(src.Deep, ctx); + target.Ctx = ctx; + return target; + """ + ) + .HaveMethodBody( + "MapDeep", + """ + var target = new global::CDeep(); + target.Value = src.Value; + target.Ctx = ctx; + return target; + """ + ); + } + + [Fact] + public void MapPropertyFromSourceUseWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapPropertyFromSource(nameof(B.IsLiked), Use = nameof(GetIsLiked))] + partial B Map(A src, int currentUserId); + private static bool GetIsLiked(A record, int currentUserId) => record.Likes.Any(l => l.UserId == currentUserId); + """, + """ + class A { public List Likes { get; set; } } + class B { public bool IsLiked { get; set; } } + class Like { public int UserId { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.IsLiked = GetIsLiked(src, currentUserId); + return target; + """ + ); + } + + [Fact] + public void MapValueUseMethodWithVerbatimIdentifierParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(GetValue))] + partial B Map(A src, int @class); + private int GetValue(int @class) => @class * 2; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + target.IntValue = GetValue(@class); + return target; + """ + ); + } + + [Fact] + public void UnusedParameterWithNoConsumerShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A src, int unusedParam);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.AdditionalParameterNotMapped, + "The additional mapping method parameter unusedParam of the method Map is not mapped" + ) + .HaveAssertedAllDiagnostics() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.StringValue = src.StringValue; + return target; + """ + ); + } + + [Fact] + public void ExternalMapperMethodWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [UseMapper] + private readonly OtherMapper _otherMapper = new(); + + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(@_otherMapper.Transform))] + partial B Map(A src, int multiplier); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }", + "class OtherMapper { public int Transform(int value, int multiplier) => value * multiplier; }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Result = _otherMapper.Transform(src.Value, multiplier); + return target; + """ + ); + } + + [Fact] + public void ExternalStaticMapperMethodWithAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(@OtherMapper.Transform))] + partial B Map(A src, int multiplier); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }", + "static class OtherMapper { public static int Transform(int value, int multiplier) => value * multiplier; }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Result = global::OtherMapper.Transform(src.Value, multiplier); + return target; + """ + ); + } } From 3773229c917423cf4f8c9a2ebae0800a28a8147e Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 15:10:08 +0100 Subject: [PATCH 10/32] refactor: use ParameterScope as single source of truth for additional parameter tracking --- .../MemberMappingDiagnosticReporter.cs | 13 +++----- .../BuilderContext/MembersMappingState.cs | 26 ++++++---------- .../MembersMappingStateBuilder.cs | 31 +------------------ 3 files changed, 16 insertions(+), 54 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs index f46cb2d42f..36a8dc6032 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs @@ -56,15 +56,12 @@ bool requiredMembersNeedToBeMapped private static void AddUnmappedAdditionalSourceMembersDiagnostics(MappingBuilderContext ctx, MembersMappingState state) { - var scope = state.ParameterScope; - var unusedInScope = scope != null ? new HashSet(scope.GetUnusedParameterNames(), StringComparer.OrdinalIgnoreCase) : null; - foreach (var name in state.UnmappedAdditionalSourceMemberNames) - { - // Skip parameters that were consumed via ParameterScope (e.g. by MapValue Use methods) - // but not removed from the unmapped additional source members set. - if (unusedInScope != null && !unusedInScope.Contains(name)) - continue; + // Only root scopes (the original creator of the parameter scope) report unused parameters. + if (state.ParameterScope is not { IsRoot: true } scope) + return; + foreach (var name in scope.GetUnusedParameterNames()) + { ctx.ReportDiagnostic(DiagnosticDescriptors.AdditionalParameterNotMapped, name, ctx.UserMapping!.Method.Name); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs index 21822c9a55..6fc9f976e1 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs @@ -3,6 +3,7 @@ using Riok.Mapperly.Configuration.PropertyReferences; using Riok.Mapperly.Descriptors.Mappings.MemberMappings; using Riok.Mapperly.Helpers; +using Riok.Mapperly.Symbols; using Riok.Mapperly.Symbols.Members; namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; @@ -12,7 +13,6 @@ namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; /// Contains discovered but unmapped members, ignored members, etc. /// /// Source member names which are not used in a member mapping yet. -/// Additional source member names (additional mapping method parameters) which are not used in a member mapping yet. /// Target member names which are not used in a member mapping yet. /// A dictionary with all members of the target with a case-insensitive key comparer. /// All known target members. @@ -21,16 +21,14 @@ namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; /// All ignored source members names. internal class MembersMappingState( HashSet unmappedSourceMemberNames, - HashSet unmappedAdditionalSourceMemberNames, HashSet unmappedTargetMemberNames, - IReadOnlyDictionary additionalSourceMembers, IReadOnlyDictionary targetMemberCaseMapping, Dictionary targetMembers, Dictionary> memberValueConfigsByRootTargetName, Dictionary> memberConfigsByRootTargetName, Dictionary> configuredTargetMembersByRootName, HashSet ignoredSourceMemberNames, - ParameterScope? parameterScope = null + ParameterScope parameterScope ) { private readonly Dictionary _aliasedSourceMembers = new(StringComparer.OrdinalIgnoreCase); @@ -40,17 +38,12 @@ internal class MembersMappingState( /// private readonly HashSet _unmappedSourceMemberNames = unmappedSourceMemberNames; - /// - /// All additional source member names (additional mapping method parameters) that are not used in a member mapping (yet). - /// - private readonly HashSet _unmappedAdditionalSourceMemberNames = unmappedAdditionalSourceMemberNames; - /// /// All target member names that are not used in a member mapping (yet). /// private readonly HashSet _unmappedTargetMemberNames = unmappedTargetMemberNames; - public ParameterScope? ParameterScope => parameterScope; + public ParameterScope ParameterScope => parameterScope; public IReadOnlyCollection IgnoredSourceMemberNames => ignoredSourceMemberNames; @@ -62,10 +55,12 @@ internal class MembersMappingState( /// public IEnumerable UnmappedSourceMemberNames => _unmappedSourceMemberNames; - /// - public IEnumerable UnmappedAdditionalSourceMemberNames => _unmappedAdditionalSourceMemberNames; - - public IReadOnlyDictionary AdditionalSourceMembers => additionalSourceMembers; + public IReadOnlyDictionary AdditionalSourceMembers => + field ??= parameterScope.Parameters.Values.ToDictionary( + x => x.NormalizedName, + x => new ParameterSourceMember(x), + StringComparer.OrdinalIgnoreCase + ); public IReadOnlyDictionary AliasedSourceMembers => _aliasedSourceMembers; @@ -213,8 +208,7 @@ private void SetSourceMemberMapped(SourceMemberPath sourcePath) _unmappedSourceMemberNames.Remove(sourceMember.Name); break; case SourceMemberType.AdditionalMappingMethodParameter: - _unmappedAdditionalSourceMemberNames.Remove(sourceMember.Name.TrimStart('@')); - parameterScope?.MarkUsed(sourceMember.Name); + parameterScope.MarkUsed(sourceMember.Name); break; } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index 99467595bc..151a912902 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -10,9 +10,6 @@ namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; internal static class MembersMappingStateBuilder { - private static readonly IReadOnlyDictionary _emptyAdditionalSourceMembers = - new Dictionary(); - public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapping) { // build configurations @@ -36,9 +33,6 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp // build all members var unmappedSourceMemberNames = GetSourceMemberNames(ctx, mapping); - var additionalSourceMembers = GetAdditionalSourceMembers(ctx); - var unmappedAdditionalSourceMemberNames = new HashSet(additionalSourceMembers.Keys, StringComparer.Ordinal); - var parameterScope = BuildParameterScope(ctx); var targetMembers = GetTargetMembers(ctx, mapping); // build ignored members @@ -59,37 +53,14 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp var unmappedTargetMemberNames = targetMembers.Keys.ToHashSet(); return new MembersMappingState( unmappedSourceMemberNames, - unmappedAdditionalSourceMemberNames, unmappedTargetMemberNames, - additionalSourceMembers, targetMemberCaseMapping, targetMembers, memberValueConfigsByRootTargetName, memberConfigsByRootTargetName, configuredTargetMembersByRootName.AsDictionary(), ignoredSourceMemberNames, - parameterScope - ); - } - - private static (IReadOnlyDictionary Members, ParameterScope Scope) BuildAdditionalSourceMembersAndScope( - MappingBuilderContext ctx - ) - { - // The copy-constructor provides the scope: inherited scopes are wrapped as child scopes, - // while own scopes (from BuildParameterScope) remain root scopes. - if (ctx.ParameterScope is { IsEmpty: false } scope) - return (BuildSourceMembersFromParameters(scope.Parameters.Values), scope); - - return (_emptyAdditionalSourceMembers, ParameterScope.Empty); - } - - private static IReadOnlyDictionary BuildSourceMembersFromParameters(IEnumerable parameters) - { - return parameters.ToDictionary( - x => x.NormalizedName, - x => new ParameterSourceMember(x), - StringComparer.OrdinalIgnoreCase + ctx.ParameterScope ?? ParameterScope.Empty ); } From a49de46d2fb2c5ae8d5fc64ed8221d0c5c7dc474 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 15:21:56 +0100 Subject: [PATCH 11/32] test: add tests for additional parameter forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapPropertyFromSource(Use=...) unsatisfiable params → RMG097 - MapValue(Use=...) partially matching params → RMG096 + RMG082 - Nested mapping with multiple additional params - MappingTarget + MapProperty(Use=...) with params - Param consumed only by Use method → no false RMG082 - Param consumed only by nested mapping → no false RMG082 - Multiple unused params → multiple RMG082 - Init-only and constructor params from additional parameters - Nested projection inlining with additional params - Collection mapping test for additional parameter forwarding --- .../Mapping/QueryableProjectionTest.cs | 18 ++ ...MethodAdditionalParameterForwardingTest.cs | 266 ++++++++++++++++++ ...gWithAdditionalParams#Mapper.g.verified.cs | 20 ++ 3 files changed, 304 insertions(+) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithNestedUserMappingWithAdditionalParams#Mapper.g.verified.cs diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs index 4980b6f862..cd4ea00879 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs @@ -256,6 +256,24 @@ class B { public string Name { get; set; } public int CurrentUserId { get; set; return TestHelper.VerifyGenerator(source); } + [Fact] + public Task ProjectionWithNestedUserMappingWithAdditionalParams() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source, int currentUserId); + private BNested MapNested(ANested src, int currentUserId) => new BNested { Value = src.Value, UserId = currentUserId }; + """, + """ + class A { public string Name { get; set; } public ANested Nested { get; set; } } + class B { public string Name { get; set; } public BNested Nested { get; set; } } + class ANested { public int Value { get; set; } } + class BNested { public int Value { get; set; } public int UserId { get; set; } } + """ + ); + return TestHelper.VerifyGenerator(source); + } + [Fact] public async Task TopLevelUserImplemented() { diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index f1a098c35c..eb1bc65b01 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -110,6 +110,34 @@ class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } ); } + [Fact] + public void NestedMappingWithUserImplementedMethodAndAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + private BNested MapNested(ANested src, int ctx) => new BNested { ValueA = src.ValueA, Ctx = ctx }; + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapNested(src.Nested, ctx); + return target; + """ + ); + } + [Fact] public void NestedMappingFallsBackToParameterlessWhenNoMatchingUserMethod() { @@ -558,6 +586,244 @@ public void UnusedParameterWithNoConsumerShouldDiagnostic() ); } + [Fact] + public void MapPropertyFromSourceUseWithUnsatisfiableParametersShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapPropertyFromSource(nameof(B.IsLiked), Use = nameof(GetIsLiked))] + partial B Map(A src); + private static bool GetIsLiked(A record, int currentUserId) => record.Likes.Any(l => l.UserId == currentUserId); + """, + """ + class A { public List Likes { get; set; } } + class B { public bool IsLiked { get; set; } } + class Like { public int UserId { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied) + .HaveDiagnostic(DiagnosticDescriptors.CouldNotMapMember) + .HaveDiagnostic(DiagnosticDescriptors.SourceMemberNotMapped) + .HaveDiagnostic(DiagnosticDescriptors.SourceMemberNotFound) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void MapValueUseMethodWithPartiallyMatchingParamsShouldDiagnostic() + { + // Method has two params (first, second) but scope only has one (first) — should fail + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("IntValue", Use = nameof(Combine))] + partial B Map(A src, int first); + private int Combine(int first, int second) => first + second; + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.MapValueMethodParametersUnsatisfied, + "The method Combine referenced by MapValue has parameters that cannot be matched from the mapping's additional parameters" + ) + .HaveDiagnostic( + DiagnosticDescriptors.AdditionalParameterNotMapped, + "The additional mapping method parameter first of the method Map is not mapped" + ) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void NestedMappingWithMultipleAdditionalParameters() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx, string label); + private partial BNested MapNested(ANested src, int ctx, string label); + """, + """ + class A { public ANested Nested { get; set; } } + class B { public BNested Nested { get; set; } } + class ANested { public int ValueA { get; set; } } + class BNested { public int ValueA { get; set; } public int Ctx { get; set; } public string Label { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Nested = MapNested(src.Nested, ctx, label); + return target; + """ + ); + } + + [Fact] + public void ExistingTargetWithMapPropertyUse() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + partial void Update(A src, [MappingTarget] B target, int multiplier); + private partial int Transform(int value, int multiplier); + """, + "class A { public int Value { get; set; } }", + "class B { public int Result { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMethodBody( + "Update", + """ + target.Result = Transform(src.Value, multiplier); + """ + ); + } + + [Fact] + public void MultipleUnusedParametersShouldEmitMultipleDiagnostics() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A src, int unused1, string unused2);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostics( + DiagnosticDescriptors.AdditionalParameterNotMapped, + "The additional mapping method parameter unused1 of the method Map is not mapped", + "The additional mapping method parameter unused2 of the method Map is not mapped" + ) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void InitOnlyPropertyMappedFromAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A src, int ctx);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int Ctx { get; init; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B() + { + Ctx = ctx, + }; + target.StringValue = src.StringValue; + return target; + """ + ); + } + + [Fact] + public void ConstructorParameterMappedFromAdditionalParameter() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A src, int ctx);", + "class A { public string StringValue { get; set; } }", + "class B { public B(int ctx) { Ctx = ctx; } public string StringValue { get; set; } public int Ctx { get; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(ctx); + target.StringValue = src.StringValue; + return target; + """ + ); + } + + [Fact] + public void CollectionMappingWithUserDefinedElementMapping() + { + // The auto-generated collection wrapper does not forward additional params directly. + // The user-defined MapItem is used as the element mapping within the wrapper. + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + private partial BItem MapItem(AItem src, int ctx); + """, + """ + class A { public List Items { get; set; } } + class B { public List Items { get; set; } } + class AItem { public int Value { get; set; } } + class BItem { public int Value { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Items = MapToListOfBItem(src.Items); + return target; + """ + ) + .HaveMethodBody( + "MapItem", + """ + var target = new global::BItem(); + target.Value = src.Value; + target.Ctx = ctx; + return target; + """ + ); + } + + [Fact] + public void CollectionMappingWithUserImplementedElementMapping() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int ctx); + private BItem MapItem(AItem src, int ctx) => new BItem { Value = src.Value, Ctx = ctx }; + """, + """ + class A { public List Items { get; set; } } + class B { public List Items { get; set; } } + class AItem { public int Value { get; set; } } + class BItem { public int Value { get; set; } public int Ctx { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveMapMethodBody( + """ + var target = new global::B(); + target.Items = MapToListOfBItem(src.Items); + return target; + """ + ); + } + [Fact] public void ExternalMapperMethodWithAdditionalParameter() { diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithNestedUserMappingWithAdditionalParams#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithNestedUserMappingWithAdditionalParams#Mapper.g.verified.cs new file mode 100644 index 0000000000..fb5fdc1abd --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionWithNestedUserMappingWithAdditionalParams#Mapper.g.verified.cs @@ -0,0 +1,20 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source, int currentUserId) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Name = x.Name, + Nested = new global::BNested { Value = x.Nested.Value, UserId = currentUserId }, + } + ); +#nullable enable + } +} \ No newline at end of file From b1a701295d7b48b6828e5f164cc34e3c69c907d6 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 13:22:03 +0100 Subject: [PATCH 12/32] docs: document additional parameter forwarding feature fix: move diagnostics to AnalyzerReleases.Shipped.md and add diagnostic docs Per contributing guidelines: - Move RMG096/RMG097 from Unshipped to Shipped (Mapperly does not use Unshipped) - Add RMG096.mdx and RMG097.mdx diagnostic documentation pages --- .../additional-mapping-parameters.mdx | 109 +++++++++++++++++- .../analyzer-diagnostics/RMG097.mdx | 46 ++++++++ .../constant-generated-values.mdx | 22 ++++ docs/docs/configuration/mapper.mdx | 29 +++++ .../configuration/queryable-projections.mdx | 43 +++++++ src/Riok.Mapperly/AnalyzerReleases.Shipped.md | 2 + .../Diagnostics/DiagnosticDescriptors.cs | 5 +- 7 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 docs/docs/configuration/analyzer-diagnostics/RMG097.mdx diff --git a/docs/docs/configuration/additional-mapping-parameters.mdx b/docs/docs/configuration/additional-mapping-parameters.mdx index 6643841abb..07f55ebf62 100644 --- a/docs/docs/configuration/additional-mapping-parameters.mdx +++ b/docs/docs/configuration/additional-mapping-parameters.mdx @@ -61,10 +61,117 @@ but higher than a by-name matched regular member mapping. +## Forwarding parameters to nested mappings + +Additional parameters can be forwarded to user-defined nested mapping methods. +When Mapperly resolves a nested type mapping (e.g., `A.Nested → B.Nested`), +it will prefer a user-defined mapping method whose additional parameters can be satisfied +from the parent mapping's additional parameters, matched by name. + +Mapperly will **not** auto-generate mapping methods with additional parameters. +Only explicitly declared user-defined methods are considered. + + + + ```csharp + [Mapper] + public partial class OrderMapper + { + public partial OrderDto Map(Order source, int currentUserId); + // highlight-start + // Mapperly will call MapItem with the currentUserId parameter forwarded + private partial OrderItemDto MapItem(OrderItem source, int currentUserId); + // highlight-end + } + ``` + + + ```csharp + public partial OrderDto Map(Order source, int currentUserId) + { + var target = new OrderDto(); + // highlight-start + target.Item = MapItem(source.Item, currentUserId); + // highlight-end + return target; + } + ``` + + + +## Forwarding parameters to Use methods + +Additional parameters can be passed to methods referenced by `MapValue(Use = ...)`. +The method's parameters are matched by name from the mapping's additional parameters. + + + + ```csharp + [Mapper] + public partial class OrderMapper + { + // highlight-start + [MapValue(nameof(OrderDto.Total), Use = nameof(ComputeTotal))] + // highlight-end + public partial OrderDto Map(Order source, decimal taxRate); + + // highlight-start + private decimal ComputeTotal(decimal taxRate) => 100m * (1 + taxRate); + // highlight-end + } + ``` + + + ```csharp + public partial OrderDto Map(Order source, decimal taxRate) + { + var target = new OrderDto(); + // highlight-start + target.Total = ComputeTotal(taxRate); + // highlight-end + return target; + } + ``` + + + +Additional parameters can also be forwarded to methods referenced by `MapProperty(Use = ...)`. +The first parameter of the `Use` method receives the source member value; +the remaining parameters are matched by name from the mapping's additional parameters. +This works with both mapper-internal methods and [external mapper](./external-mappings.mdx) methods. +See also [user-implemented property mappings](./mapper.mdx#user-implemented-property-mappings). + +## Additional parameters in queryable projections + +Additional parameters work in queryable projections. +The parameters become captured variables in the generated projection lambda. +User-implemented methods with additional parameters are inlined following the same rules +as regular [user-implemented mapping methods in projections](./queryable-projections.mdx#user-implemented-mapping-methods). + +```csharp +[Mapper] +public static partial class OrderMapper +{ + // highlight-start + public static partial IQueryable ProjectToDto( + this IQueryable q, + int currentUserId + ); + // highlight-end +} + +// Generated: +// source.Select(x => new OrderDto() +// { +// ... +// CurrentUserId = currentUserId, // captured from outer scope +// }); +``` + :::info Mappings with additional parameters do have some limitations: -- The additional parameters are not passed to nested mappings. +- Additional parameters are forwarded to user-defined nested mapping methods only (not auto-generated ones). - A mapping with additional mapping parameters cannot be the default mapping (it is not used by Mapperly when encountering a nested mapping for the given types), see also [default mapping methods](./user-implemented-methods.mdx##default-mapping-methods). diff --git a/docs/docs/configuration/analyzer-diagnostics/RMG097.mdx b/docs/docs/configuration/analyzer-diagnostics/RMG097.mdx new file mode 100644 index 0000000000..aade1cef6d --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/RMG097.mdx @@ -0,0 +1,46 @@ +--- +sidebar_label: RMG097 +description: 'Mapperly analyzer diagnostic RMG097 — MapValue Use method parameters cannot be satisfied' +--- + +# RMG097 — MapValue Use method parameters cannot be satisfied + +A method referenced by `MapValue(Use = ...)` has parameters that cannot be matched +from the mapping method's [additional parameters](../additional-mapping-parameters.mdx). + +To fix this, ensure the mapping method declares additional parameters with names matching +the parameters of the `Use` method. + +## Example + +`GetValue` requires an `int ctx` parameter, +but the `Map` method does not declare a matching additional parameter. + +```csharp +[Mapper] +public partial class MyMapper +{ + // highlight-start + // Error: GetValue requires 'ctx' but Map has no additional parameters + [MapValue(nameof(B.Value), Use = nameof(GetValue))] + // highlight-end + public partial B Map(A source); + + private int GetValue(int ctx) => ctx * 2; +} +``` + +Add the missing parameter to the mapping method: + +```csharp +[Mapper] +public partial class MyMapper +{ + [MapValue(nameof(B.Value), Use = nameof(GetValue))] + // highlight-start + public partial B Map(A source, int ctx); + // highlight-end + + private int GetValue(int ctx) => ctx * 2; +} +``` diff --git a/docs/docs/configuration/constant-generated-values.mdx b/docs/docs/configuration/constant-generated-values.mdx index 5ad8f65efa..18fd75336c 100644 --- a/docs/docs/configuration/constant-generated-values.mdx +++ b/docs/docs/configuration/constant-generated-values.mdx @@ -54,6 +54,28 @@ Make sure the return type exactly matches the target type. This also works for constructor parameters. +### Using additional mapping parameters + +When the mapping method has [additional parameters](./additional-mapping-parameters.mdx), +they can be passed to `MapValue(Use = ...)` methods. +The method's parameters are matched by name from the mapping's additional parameters: + + + + ```csharp + [MapValue(nameof(CarDto.FormattedDate), Use = nameof(FormatDate))] + public partial CarDto Map(Car car, string dateFormat); + + string FormatDate(string dateFormat) => DateTime.Now.ToString(dateFormat); + ``` + + + ```csharp + target.FormattedDate = FormatDate(dateFormat); + ``` + + + ### Named mapping The name of the value generator can be overridden by the `NamedMapping` attribute: diff --git a/docs/docs/configuration/mapper.mdx b/docs/docs/configuration/mapper.mdx index 7b100e52bf..cfb012f76c 100644 --- a/docs/docs/configuration/mapper.mdx +++ b/docs/docs/configuration/mapper.mdx @@ -444,6 +444,35 @@ public partial class CarMapper } ``` +#### Use methods with additional parameters + +When the mapping method has [additional parameters](./additional-mapping-parameters.mdx), +they can be forwarded to `Use` methods. +For `MapProperty(Use = ...)`, the first parameter of the `Use` method receives the source member value, +and the remaining parameters are matched by name from the mapping's additional parameters: + +```csharp +[Mapper] +public partial class CarMapper +{ + // highlight-start + [MapProperty(nameof(Car.Price), nameof(CarDto.DisplayPrice), Use = nameof(FormatPrice))] + // highlight-end + public partial CarDto MapCar(Car source, string currency); + + // highlight-start + [UserMapping(Default = false)] + private string FormatPrice(decimal price, string currency) + => $"{currency} {price:F2}"; + // highlight-end + + // generates + target.DisplayPrice = FormatPrice(source.Price, currency); +} +``` + +See also [additional mapping parameters](./additional-mapping-parameters.mdx) for more details on parameter forwarding. + #### Custom mapping names By default, Mapperly uses the method name as the identifier for each mapping. diff --git a/docs/docs/configuration/queryable-projections.mdx b/docs/docs/configuration/queryable-projections.mdx index a729e86156..c0278d4749 100644 --- a/docs/docs/configuration/queryable-projections.mdx +++ b/docs/docs/configuration/queryable-projections.mdx @@ -122,6 +122,49 @@ public static partial class CarMapper It is important that the types in the user-implemented mapping method match the types of the objects to be mapped exactly. Otherwise, Mapperly cannot resolve the user-implemented mapping methods. +## Additional parameters + +Queryable projection methods can have [additional parameters](./additional-mapping-parameters.mdx). +The parameters become captured variables in the generated projection lambda expression: + + + + +```csharp +[Mapper] +public static partial class CarMapper +{ + // highlight-start + public static partial IQueryable ProjectToDto(this IQueryable q, int currentUserId); + // highlight-end +} +``` + + + + +```csharp +public static partial IQueryable ProjectToDto(this IQueryable q, int currentUserId) +{ + return System.Linq.Queryable.Select( + q, + x => new CarDto() + { + Brand = x.Brand, + // highlight-start + CurrentUserId = currentUserId, // captured from outer scope + // highlight-end + }); +} +``` + + + + +User-implemented mapping methods with additional parameters are inlined into the projection expression. +The same inlining rules and limitations apply as for +[regular user-implemented mapping methods](#user-implemented-mapping-methods). + ## Expression mappings In addition to `IQueryable` projections, Mapperly also supports generating `Expression>` directly. diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index d095375063..b18476a711 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -222,3 +222,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- RMG095 | Mapper | Warning | Invalid MSBuild configuration option RMG096 | Mapper | Hidden | A MapperIgnore* attribute does not specify a justification +RMG097 | Mapper | Error | MapValue Use method parameters cannot be satisfied +RMG098 | Mapper | Error | Named mapping additional parameters cannot be satisfied diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 4bd7961698..4eb537a172 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -829,12 +829,13 @@ public static class DiagnosticDescriptors ); public static readonly DiagnosticDescriptor NamedMappingParametersUnsatisfied = new( - "RMG097", + "RMG098", "Named mapping additional parameters cannot be satisfied", "The named mapping {0} has additional parameters that cannot be matched from the caller's scope", DiagnosticCategories.Mapper, DiagnosticSeverity.Error, - true + true, + helpLinkUri: BuildHelpUri("RMG098") ); private static string BuildHelpUri(string id) From fa7dab737633e14306b984cada656add08dab41d Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 18:33:09 +0100 Subject: [PATCH 13/32] feat: projection config discovery matches element mappings by additional parameters Projections with additional parameters now only pick up MapProperty/ MapPropertyFromSource configurations from element mappings whose additional parameters match. No fallback to default element mappings that ignore the parameters. Projections without additional parameters continue to use the default element mapping as before. --- .../InlineExpressionMappingBuilderContext.cs | 9 +- .../Descriptors/MappingBuilderContext.cs | 3 + .../InlineExpressionMappingBuilder.cs | 9 +- .../Mapping/QueryableProjectionTest.cs | 96 +++++++++++++++++++ ...gWithAdditionalParams#Mapper.g.verified.cs | 29 ++++++ 5 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionShouldPickUpConfigFromElementMappingWithAdditionalParams#Mapper.g.verified.cs diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs index b61fb9ab41..35b6066e21 100644 --- a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs @@ -123,11 +123,14 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi // inline expression mappings don't reuse the user-defined mappings directly // but to apply the same configurations the default mapping user symbol is used // if there is no other user symbol. - // this makes sure the configuration of the default mapping user symbol is used - // for inline expression mappings. // This is not needed for regular mappings as these user defined method mappings // are directly built (with KeepUserSymbol) and called by the other mappings. - userMapping ??= MappingBuilder.Find(mappingKey) as IUserMapping; + // When additional parameters are available, only match element mappings whose + // parameters all match — don't fall back to a default mapping that ignores the params. + userMapping ??= ParameterScope is { IsEmpty: false } + ? MappingBuilder.FindUserMappingWithParameters(mappingKey, ParameterScope) as IUserMapping + : MappingBuilder.Find(mappingKey) as IUserMapping; + options &= ~MappingBuildingOptions.KeepUserSymbol; return BuildMapping(userMapping, mappingKey, options, diagnosticLocation); } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index bd605d3965..97ae9de919 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -152,6 +152,9 @@ protected MappingBuilderContext( /// The found mapping, or null if none is found. public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) => MappingBuilder.Find(mappingKey); + public INewInstanceMapping? FindUserMappingWithParameters(TypeMappingKey mappingKey, ParameterScope scope) => + MappingBuilder.FindUserMappingWithParameters(mappingKey, scope); + /// /// Tries to find an existing mapping for the provided types. /// If none is found, a new one is created. diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs index d92f975020..de2b8a3304 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs @@ -23,7 +23,14 @@ ITypeSymbol targetType ) { var mappingKey = BuildMappingKey(ctx, sourceType, targetType); - var userMapping = ctx.FindMapping(sourceType, targetType) as IUserMapping; + + // When the projection has additional parameters, only match an element mapping + // whose additional parameters all match — don't fall back to a default mapping + // that has no knowledge of the additional params. + // When the projection has no additional parameters, use the default element mapping. + var userMapping = ctx.ParameterScope is { IsEmpty: false } scope + ? ctx.FindUserMappingWithParameters(new TypeMappingKey(sourceType, targetType), scope) as IUserMapping + : ctx.FindMapping(sourceType, targetType) as IUserMapping; var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, userMapping, mappingKey); if (userMapping is UserImplementedMethodMapping && inlineCtx.FindMapping(sourceType, targetType) is { } inlinedUserMapping) diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs index cd4ea00879..0c7e193fe8 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs @@ -274,6 +274,102 @@ class BNested { public int Value { get; set; } public int UserId { get; set; } } return TestHelper.VerifyGenerator(source); } + [Fact] + public Task ProjectionShouldPickUpConfigFromElementMappingWithAdditionalParams() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source, int currentUserId); + [MapProperty("SourceValue", "TargetValue")] + [MapPropertyFromSource(nameof(B.IsLiked), Use = nameof(GetIsLiked))] + private partial B MapToB(A source, int currentUserId); + private static bool GetIsLiked(A record, int currentUserId) => record.Likes.Any(l => l.UserId == currentUserId); + """, + """ + class A { public string SourceValue { get; set; } public List Likes { get; set; } } + class B { public string TargetValue { get; set; } public bool IsLiked { get; set; } } + class Like { public int UserId { get; set; } } + """ + ); + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public void ProjectionWithParamsShouldNotPickUpElementMappingWithMismatchedParams() + { + // Projection has 'currentUserId' but element mapping has 'otherParam' — no match. + // The MapProperty config should NOT be applied to the projection. + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source, int currentUserId); + [MapProperty("SourceValue", "TargetValue")] + private partial B MapToB(A source, int otherParam); + """, + """ + class A { public string SourceValue { get; set; } public string TargetValue { get; set; } } + class B { public string TargetValue { get; set; } public int CurrentUserId { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveMapMethodBody( + // SourceValue → TargetValue mapping is NOT applied (no matching element mapping), + // instead TargetValue is mapped by name from A.TargetValue, and currentUserId by name. + """ + #nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + TargetValue = x.TargetValue, + CurrentUserId = currentUserId, + } + ); + #nullable enable + """ + ); + } + + [Fact] + public void ProjectionWithoutParamsShouldNotPickUpParameterizedElementMapping() + { + // Projection has NO additional params, element mapping HAS params. + // The default (parameterless) mapping should be used, not the parameterized one. + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source); + [MapProperty("SourceValue", "TargetValue")] + private partial B MapToB(A source, int someParam); + """, + """ + class A { public string SourceValue { get; set; } public string Name { get; set; } } + class B { public string Name { get; set; } public string TargetValue { get; set; } } + """ + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveMapMethodBody( + // MapProperty("SourceValue", "TargetValue") is NOT applied because + // the projection has no params and the element mapping is non-default (has params). + // SourceValue has no matching target, Name maps by name. + """ + #nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Name = x.Name, + } + ); + #nullable enable + """ + ); + } + [Fact] public async Task TopLevelUserImplemented() { diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionShouldPickUpConfigFromElementMappingWithAdditionalParams#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionShouldPickUpConfigFromElementMappingWithAdditionalParams#Mapper.g.verified.cs new file mode 100644 index 0000000000..30f2ea1ab2 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionTest.ProjectionShouldPickUpConfigFromElementMappingWithAdditionalParams#Mapper.g.verified.cs @@ -0,0 +1,29 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source, int currentUserId) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + TargetValue = x.SourceValue, + IsLiked = global::System.Linq.Enumerable.Any(x.Likes, l => l.UserId == currentUserId), + } + ); +#nullable enable + } + + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::B MapToB(global::A source, int currentUserId) + { + var target = new global::B(); + target.TargetValue = source.SourceValue; + target.IsLiked = GetIsLiked(source, currentUserId); + return target; + } +} \ No newline at end of file From 9271176ff3f0095ec9e136836935e9ef92349ede Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 18:36:25 +0100 Subject: [PATCH 14/32] docs: document projection property configuration with additional parameters feat: support additional parameters on external and MapPropertyFromSource Use methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove IsExternal guard from BuildArguments in UserImplementedMethodMapping and UserImplementedExistingTargetMethodMapping — external mapper methods now forward additional parameters like internal ones - Always allow additional parameters in BuildUserImplementedMapping (remove allowAdditionalParameters parameter — was true at all call sites) - Enable MapPropertyFromSource(Use=...) with additional parameters by discovering user-implemented methods with extra params during initial extraction --- .../configuration/queryable-projections.mdx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/docs/configuration/queryable-projections.mdx b/docs/docs/configuration/queryable-projections.mdx index c0278d4749..55874bdd17 100644 --- a/docs/docs/configuration/queryable-projections.mdx +++ b/docs/docs/configuration/queryable-projections.mdx @@ -165,6 +165,33 @@ User-implemented mapping methods with additional parameters are inlined into the The same inlining rules and limitations apply as for [regular user-implemented mapping methods](#user-implemented-mapping-methods). +### Property configurations with additional parameters + +When a projection has additional parameters and you want to apply [property configurations](#property-configurations), +the element mapping method must have the **same additional parameters**. +A projection with additional parameters will only pick up configurations from an element mapping +whose additional parameters match by name. + +```csharp +[Mapper] +public static partial class CarMapper +{ + // highlight-start + public static partial IQueryable ProjectToDto(this IQueryable q, int currentUserId); + // highlight-end + + // highlight-start + // This element mapping is used for configuration because its additional parameter matches. + [MapProperty(nameof(Car.Manufacturer), nameof(CarDto.Producer))] + [MapPropertyFromSource(nameof(CarDto.IsLiked), Use = nameof(GetIsLiked))] + // highlight-end + private static partial CarDto Map(Car car, int currentUserId); + + private static bool GetIsLiked(Car car, int currentUserId) + => car.Likes.Any(l => l.UserId == currentUserId); +} +``` + ## Expression mappings In addition to `IQueryable` projections, Mapperly also supports generating `Expression>` directly. From d0960320ef0a87be428302700efc21fb8c0e1836 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 18 Mar 2026 21:54:13 +0100 Subject: [PATCH 15/32] refactor: remove dead allowAdditionalParameters parameter from BuildParameters --- .../Descriptors/UserMappingMethodParameterExtractor.cs | 7 ------- .../Descriptors/UserMethodMappingExtractor.cs | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs index 034f5ea3b7..53169bb48b 100644 --- a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs @@ -12,7 +12,6 @@ internal static class UserMappingMethodParameterExtractor public static bool BuildParameters( SimpleMappingBuilderContext ctx, IMethodSymbol method, - bool allowAdditionalParameters, [NotNullWhen(true)] out MappingMethodParameters? parameters ) { @@ -45,12 +44,6 @@ public static bool BuildParameters( p.Ordinal != sourceParameter.Value.Ordinal && p.Ordinal != targetParameterOrdinal && p.Ordinal != refHandlerParameterOrdinal ) .ToList(); - if (!allowAdditionalParameters && additionalParameterSymbols.Count > 0) - { - parameters = null; - return false; - } - // additional parameters should not be attributed as target or ref handler var hasInvalidAdditionalParameter = additionalParameterSymbols.Exists(p => p.Type.TypeKind is TypeKind.TypeParameter or TypeKind.Error diff --git a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs index 1b745c06a0..438d9be3c9 100644 --- a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs @@ -169,7 +169,7 @@ private static bool IsMappingMethodCandidate(SimpleMappingBuilderContext ctx, IM var userMappingConfig = GetUserMappingConfig(ctx, method, out var hasAttribute); var valid = !method.IsGenericMethod && (allowPartial || !method.IsPartialDefinition) && (!isStatic || method.IsStatic); - if (!valid || !UserMappingMethodParameterExtractor.BuildParameters(ctx, method, true, out var parameters)) + if (!valid || !UserMappingMethodParameterExtractor.BuildParameters(ctx, method, out var parameters)) { if (!hasAttribute) return null; @@ -246,7 +246,7 @@ string sourceParameterName if (TryBuildExpressionMapping(ctx, methodSymbol) is { } expressionMapping) return expressionMapping; - if (!UserMappingMethodParameterExtractor.BuildParameters(ctx, methodSymbol, true, out var parameters)) + if (!UserMappingMethodParameterExtractor.BuildParameters(ctx, methodSymbol, out var parameters)) { ctx.ReportDiagnostic(DiagnosticDescriptors.UnsupportedMappingMethodSignature, methodSymbol, methodSymbol.Name); return null; From 34a6262532042cda8a74c68296ef6247246849c3 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Fri, 20 Mar 2026 08:57:59 +0100 Subject: [PATCH 16/32] refactor: consolidate ParameterScope.MarkUsed into batch overloads --- .../MappingBodyBuilders/SourceValueBuilder.cs | 6 +---- .../Descriptors/MappingBuilderContext.cs | 5 +---- .../MappingBuilders/UseNamedMappingBuilder.cs | 5 +---- .../Descriptors/ParameterScope.cs | 22 +++++++++++++++++++ 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index b77efba969..c52d7099d4 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -206,11 +206,7 @@ private static bool TryBuildMethodProvidedSourceValue( // Collect additional parameter names and mark them as used var additionalParameterNames = methodSymbol.Parameters.Select(param => param.Name).ToList(); - - foreach (var additionalParameterName in additionalParameterNames) - { - scope?.MarkUsed(additionalParameterName); - } + scope?.MarkUsed(additionalParameterNames); sourceValue = new MethodProvidedSourceValue( methodSymbol.Name, diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 97ae9de919..56414f5896 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -79,10 +79,7 @@ protected MappingBuilderContext( if (mapping is not IParameterizedMapping parameterized) return null; - foreach (var param in parameterized.AdditionalSourceParameters) - { - ParameterScope.MarkUsed(param.Name); - } + ParameterScope.MarkUsed(parameterized.AdditionalSourceParameters); return mapping; } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs index ba3c1250e5..4c9f14d5ef 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs @@ -156,10 +156,7 @@ private static bool ValidateAndMarkAdditionalParameters(MappingBuilderContext ct return false; } - foreach (var param in matched) - { - scope.MarkUsed(param.Name); - } + scope.MarkUsed(matched); return true; } diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index 9a93126388..3ed7a30420 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -84,6 +84,28 @@ public void MarkUsed(string name) _usedParameters?.Add(NormalizeName(name)); } + /// + /// Mark all parameters in the collection as used. + /// + public void MarkUsed(IEnumerable parameters) + { + foreach (var param in parameters) + { + MarkUsed(param.Name); + } + } + + /// + /// Mark all named parameters as used. + /// + public void MarkUsed(IEnumerable names) + { + foreach (var name in names) + { + MarkUsed(name); + } + } + private static string NormalizeName(string name) => name.TrimStart('@'); /// From 358b9a9d87e7ed11dc7b23046517a948a6f693fa Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 21 Mar 2026 23:29:48 +0100 Subject: [PATCH 17/32] refactor: make ParameterScope non-nullable with Empty singleton default --- .../InlineExpressionMappingBuilderContext.cs | 6 +++--- .../BuilderContext/MembersMappingStateBuilder.cs | 2 +- .../MappingBodyBuilders/SourceValueBuilder.cs | 4 ++-- src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs | 10 +++++----- .../MappingBuilders/InlineExpressionMappingBuilder.cs | 6 +++--- .../MappingBuilders/UseNamedMappingBuilder.cs | 2 +- src/Riok.Mapperly/Descriptors/ParameterScope.cs | 6 ++---- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs index 35b6066e21..5d41744be3 100644 --- a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs @@ -127,9 +127,9 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi // are directly built (with KeepUserSymbol) and called by the other mappings. // When additional parameters are available, only match element mappings whose // parameters all match — don't fall back to a default mapping that ignores the params. - userMapping ??= ParameterScope is { IsEmpty: false } - ? MappingBuilder.FindUserMappingWithParameters(mappingKey, ParameterScope) as IUserMapping - : MappingBuilder.Find(mappingKey) as IUserMapping; + userMapping ??= ParameterScope.IsEmpty + ? MappingBuilder.Find(mappingKey) as IUserMapping + : MappingBuilder.FindUserMappingWithParameters(mappingKey, ParameterScope) as IUserMapping; options &= ~MappingBuildingOptions.KeepUserSymbol; return BuildMapping(userMapping, mappingKey, options, diagnosticLocation); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index 151a912902..632dd0bd65 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -60,7 +60,7 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp memberConfigsByRootTargetName, configuredTargetMembersByRootName.AsDictionary(), ignoredSourceMemberNames, - ctx.ParameterScope ?? ParameterScope.Empty + ctx.ParameterScope ); } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index c52d7099d4..8271fe71e2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -166,7 +166,7 @@ private static bool TryBuildMethodProvidedSourceValue( .BuilderContext.SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) .Where(m => m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false } - && ParameterScope.CanSatisfyParameters(scope, m) + && scope.CanSatisfyParameters(m) && ctx.BuilderContext.AttributeAccessor.IsMappingNameEqualTo(m, methodReferenceConfiguration.Name) ) .ToList(); @@ -206,7 +206,7 @@ private static bool TryBuildMethodProvidedSourceValue( // Collect additional parameter names and mark them as used var additionalParameterNames = methodSymbol.Parameters.Select(param => param.Name).ToList(); - scope?.MarkUsed(additionalParameterNames); + scope.MarkUsed(additionalParameterNames); sourceValue = new MethodProvidedSourceValue( methodSymbol.Name, diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 56414f5896..7342edc4a7 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -58,21 +58,21 @@ protected MappingBuilderContext( { // Wrap parent scope in a child (delegates MarkUsed upward), or initialize from user mapping. // Only the root scope reports unused parameters in diagnostics. - ParameterScope = ctx.ParameterScope is { } parentScope ? new ParameterScope(parentScope) : BuildParameterScope(userMapping); + ParameterScope = ctx.ParameterScope.IsEmpty ? BuildParameterScope(userMapping) : new ParameterScope(ctx.ParameterScope); if (ignoreDerivedTypes) { Configuration = Configuration with { DerivedTypes = [] }; } } - private static ParameterScope? BuildParameterScope(IUserMapping? userMapping) => + private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => userMapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm ? new ParameterScope(pm.AdditionalSourceParameters) - : null; + : ParameterScope.Empty; private INewInstanceMapping? FindParameterizedUserMapping(TypeMappingKey key) { - if (ParameterScope is null or { IsEmpty: true }) + if (ParameterScope.IsEmpty) return null; var mapping = MappingBuilder.FindUserMappingWithParameters(key, ParameterScope); @@ -105,7 +105,7 @@ protected MappingBuilderContext( /// public virtual bool IsExpression => false; - public ParameterScope? ParameterScope { get; } + public ParameterScope ParameterScope { get; } = ParameterScope.Empty; public InstanceConstructorFactory InstanceConstructors { get; } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs index de2b8a3304..5033036a69 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs @@ -28,9 +28,9 @@ ITypeSymbol targetType // whose additional parameters all match — don't fall back to a default mapping // that has no knowledge of the additional params. // When the projection has no additional parameters, use the default element mapping. - var userMapping = ctx.ParameterScope is { IsEmpty: false } scope - ? ctx.FindUserMappingWithParameters(new TypeMappingKey(sourceType, targetType), scope) as IUserMapping - : ctx.FindMapping(sourceType, targetType) as IUserMapping; + var userMapping = ctx.ParameterScope.IsEmpty + ? ctx.FindMapping(sourceType, targetType) as IUserMapping + : ctx.FindUserMappingWithParameters(new TypeMappingKey(sourceType, targetType), ctx.ParameterScope) as IUserMapping; var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, userMapping, mappingKey); if (userMapping is UserImplementedMethodMapping && inlineCtx.FindMapping(sourceType, targetType) is { } inlinedUserMapping) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs index 4c9f14d5ef..641b83dad8 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs @@ -150,7 +150,7 @@ private static bool ValidateAndMarkAdditionalParameters(MappingBuilderContext ct return true; var scope = ctx.ParameterScope; - if (scope is null || scope.IsEmpty || !scope.TryMatchParameters(pm.AdditionalSourceParameters, out var matched)) + if (scope.IsEmpty || !scope.TryMatchParameters(pm.AdditionalSourceParameters, out var matched)) { ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, mappingName); return false; diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index 3ed7a30420..c68de549fa 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -63,11 +63,9 @@ public bool TryMatchParameters(IReadOnlyCollection requested, o /// /// Checks whether all parameters of a method can be satisfied by this scope (by normalized name). - /// Returns true for parameterless methods. A null or empty scope can only satisfy parameterless methods. /// - public static bool CanSatisfyParameters(ParameterScope? scope, IMethodSymbol method) => - method.Parameters.Length == 0 - || (scope is { IsEmpty: false } && method.Parameters.All(p => scope._parameters.ContainsKey(NormalizeName(p.Name)))); + public bool CanSatisfyParameters(IMethodSymbol method) => + method.Parameters.All(p => _parameters.ContainsKey(NormalizeName(p.Name))); /// /// Mark a parameter as having at least one consumer (idempotent). From f5618be9bbf376110fd084b95c862101ed19828f Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 21 Mar 2026 23:56:37 +0100 Subject: [PATCH 18/32] refactor: unify ParameterScope matching API with CanMatchParameters and TryUseParameters --- .../MappingBodyBuilders/SourceValueBuilder.cs | 2 +- .../Descriptors/MappingBuilderContext.cs | 5 +-- .../MappingBuilders/UseNamedMappingBuilder.cs | 27 ++++-------- .../Descriptors/MappingCollection.cs | 2 +- .../Descriptors/ParameterScope.cs | 43 ++++++++++--------- 5 files changed, 35 insertions(+), 44 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index 8271fe71e2..c874e309b0 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -166,7 +166,7 @@ private static bool TryBuildMethodProvidedSourceValue( .BuilderContext.SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) .Where(m => m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false } - && scope.CanSatisfyParameters(m) + && scope.CanMatchParameters(m) && ctx.BuilderContext.AttributeAccessor.IsMappingNameEqualTo(m, methodReferenceConfiguration.Name) ) .ToList(); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 7342edc4a7..8a18c90965 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -76,11 +76,10 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => return null; var mapping = MappingBuilder.FindUserMappingWithParameters(key, ParameterScope); - if (mapping is not IParameterizedMapping parameterized) + if (mapping == null) return null; - ParameterScope.MarkUsed(parameterized.AdditionalSourceParameters); - + ParameterScope.TryUseParameters(mapping); return mapping; } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs index 641b83dad8..a873a56b55 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs @@ -20,8 +20,11 @@ public static class UseNamedMappingBuilder return null; } - if (!ValidateAndMarkAdditionalParameters(ctx, mapping, ctx.MappingKey.Configuration.UseNamedMapping)) + if (!ctx.ParameterScope.TryUseParameters(mapping)) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, ctx.MappingKey.Configuration.UseNamedMapping); return null; + } var differentSourceType = !SymbolEqualityComparer.IncludeNullability.Equals(ctx.Source, mapping.SourceType); var differentTargetType = !SymbolEqualityComparer.IncludeNullability.Equals(ctx.Target, mapping.TargetType); @@ -52,8 +55,11 @@ public static class UseNamedMappingBuilder if (existingTargetMapping is null) return null; - if (!ValidateAndMarkAdditionalParameters(ctx, existingTargetMapping, useNamedMapping)) + if (!ctx.ParameterScope.TryUseParameters(existingTargetMapping)) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, useNamedMapping); return null; + } var source = ctx.Source; var target = ctx.Target; @@ -144,23 +150,6 @@ IExistingTargetMapping existingTargetMapping return new CompositeMapping(outputMapping, mapping); } - private static bool ValidateAndMarkAdditionalParameters(MappingBuilderContext ctx, ITypeMapping mapping, string mappingName) - { - if (mapping is not IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm) - return true; - - var scope = ctx.ParameterScope; - if (scope.IsEmpty || !scope.TryMatchParameters(pm.AdditionalSourceParameters, out var matched)) - { - ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, mappingName); - return false; - } - - scope.MarkUsed(matched); - - return true; - } - private static INewInstanceMapping? TryMapSource(MappingBuilderContext ctx, INewInstanceMapping mapping) { // report if the source can't be assigned to the mapping source type diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 9f180b28f2..62b6a39f88 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -260,7 +260,7 @@ private class MappingCollectionInstance mapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm && SymbolEqualityComparer.IncludeNullability.Equals(key.Source, mapping.SourceType) && SymbolEqualityComparer.IncludeNullability.Equals(key.Target, mapping.TargetType) - && scope.TryMatchParameters(pm.AdditionalSourceParameters, out _) + && scope.CanMatchParameters(pm.AdditionalSourceParameters) ) { return mapping; diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index c68de549fa..40243e1ed0 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Symbols; namespace Riok.Mapperly.Descriptors; @@ -42,30 +43,32 @@ public ParameterScope(ParameterScope parent) public IReadOnlyDictionary Parameters => _parameters; /// - /// Checks if all requested additional parameters can be satisfied by this scope. - /// Matching is by name (case-insensitive). A parameter can be matched by multiple consumers. + /// Checks if all requested additional parameters can be satisfied by this scope (by normalized name). /// - public bool TryMatchParameters(IReadOnlyCollection requested, out IReadOnlyList matched) - { - var result = new List(requested.Count); - foreach (var param in requested) - { - if (!_parameters.TryGetValue(param.NormalizedName, out var scopeParam)) - { - matched = []; - return false; - } - result.Add(scopeParam); - } - matched = result; - return true; - } + public bool CanMatchParameters(IReadOnlyCollection requested) => + requested.All(p => _parameters.ContainsKey(p.NormalizedName)); /// - /// Checks whether all parameters of a method can be satisfied by this scope (by normalized name). + /// Checks if all parameters of a method can be satisfied by this scope (by normalized name). /// - public bool CanSatisfyParameters(IMethodSymbol method) => - method.Parameters.All(p => _parameters.ContainsKey(NormalizeName(p.Name))); + public bool CanMatchParameters(IMethodSymbol method) => method.Parameters.All(p => _parameters.ContainsKey(NormalizeName(p.Name))); + + /// + /// If the mapping is parameterized, checks if all its additional parameters can be + /// satisfied by this scope and marks them as used. Returns false only when the mapping + /// requires parameters that this scope cannot satisfy. + /// + public bool TryUseParameters(ITypeMapping? mapping) + { + if (mapping is not IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm) + return true; + + if (!CanMatchParameters(pm.AdditionalSourceParameters)) + return false; + + MarkUsed(pm.AdditionalSourceParameters); + return true; + } /// /// Mark a parameter as having at least one consumer (idempotent). From 8927fa8f8e41b0bdfe8eb50aef9d91d692b69ed6 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sun, 22 Mar 2026 21:10:44 +0100 Subject: [PATCH 19/32] refactor: merge FindUserMappingWithParameters into Find with optional ParameterScope When a non-empty scope is passed, Find performs a parameterized-only lookup; otherwise it does the regular dictionary lookup. This removes the separate FindUserMappingWithParameters method from the entire chain. --- .../InlineExpressionMappingBuilderContext.cs | 8 ++------ .../Descriptors/MappingBuilderContext.cs | 8 +++----- .../InlineExpressionMappingBuilder.cs | 9 +-------- .../Descriptors/MappingBuilders/MappingBuilder.cs | 6 ++---- .../Descriptors/MappingCollection.cs | 15 ++++++++------- 5 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs index 5d41744be3..2efbaf7938 100644 --- a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs @@ -75,7 +75,7 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi /// /// The mapping key. /// The if a mapping was found or null if none was found. - public override INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) + public override INewInstanceMapping? FindMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) { var mapping = InlinedMappings.Find(mappingKey, out var isInlined); if (mapping == null) @@ -125,11 +125,7 @@ conversionType is not MappingConversionType.EnumToString and not MappingConversi // if there is no other user symbol. // This is not needed for regular mappings as these user defined method mappings // are directly built (with KeepUserSymbol) and called by the other mappings. - // When additional parameters are available, only match element mappings whose - // parameters all match — don't fall back to a default mapping that ignores the params. - userMapping ??= ParameterScope.IsEmpty - ? MappingBuilder.Find(mappingKey) as IUserMapping - : MappingBuilder.FindUserMappingWithParameters(mappingKey, ParameterScope) as IUserMapping; + userMapping ??= MappingBuilder.Find(mappingKey, ParameterScope) as IUserMapping; options &= ~MappingBuildingOptions.KeepUserSymbol; return BuildMapping(userMapping, mappingKey, options, diagnosticLocation); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 8a18c90965..f41eef6d6c 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -75,7 +75,7 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => if (ParameterScope.IsEmpty) return null; - var mapping = MappingBuilder.FindUserMappingWithParameters(key, ParameterScope); + var mapping = MappingBuilder.Find(key, ParameterScope); if (mapping == null) return null; @@ -146,10 +146,8 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => /// /// The mapping key. /// The found mapping, or null if none is found. - public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) => MappingBuilder.Find(mappingKey); - - public INewInstanceMapping? FindUserMappingWithParameters(TypeMappingKey mappingKey, ParameterScope scope) => - MappingBuilder.FindUserMappingWithParameters(mappingKey, scope); + public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) => + MappingBuilder.Find(mappingKey, scope); /// /// Tries to find an existing mapping for the provided types. diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs index 5033036a69..b80dbf4130 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs @@ -22,15 +22,8 @@ public static class InlineExpressionMappingBuilder ITypeSymbol targetType ) { + var userMapping = ctx.FindMapping(new TypeMappingKey(sourceType, targetType), ctx.ParameterScope) as IUserMapping; var mappingKey = BuildMappingKey(ctx, sourceType, targetType); - - // When the projection has additional parameters, only match an element mapping - // whose additional parameters all match — don't fall back to a default mapping - // that has no knowledge of the additional params. - // When the projection has no additional parameters, use the default element mapping. - var userMapping = ctx.ParameterScope.IsEmpty - ? ctx.FindMapping(sourceType, targetType) as IUserMapping - : ctx.FindUserMappingWithParameters(new TypeMappingKey(sourceType, targetType), ctx.ParameterScope) as IUserMapping; var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, userMapping, mappingKey); if (userMapping is UserImplementedMethodMapping && inlineCtx.FindMapping(sourceType, targetType) is { } inlinedUserMapping) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs index a59e40e1c5..e4320dde35 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs @@ -42,10 +42,8 @@ public class MappingBuilder(MappingCollection mappings, MapperDeclaration mapper /// public IEnumerable ExistingTargetUserMappings => mappings.ExistingTargetUserMappings; - public INewInstanceMapping? Find(TypeMappingKey mapping) => mappings.FindNewInstanceMapping(mapping); - - public INewInstanceMapping? FindUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) => - mappings.FindNewInstanceUserMappingWithParameters(key, scope); + public INewInstanceMapping? Find(TypeMappingKey mapping, ParameterScope? scope = null) => + mappings.FindNewInstanceMapping(mapping, scope); public INewInstanceMapping? FindOrResolveNamed(SimpleMappingBuilderContext ctx, string name, out bool ambiguousName) { diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 62b6a39f88..9cf3c7737a 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -62,10 +62,8 @@ public class MappingCollection .Concat(_newInstanceMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings) .Concat(_existingTargetMappings.UsedDuplicatedNonDefaultNonReferencedUserMappings); - public INewInstanceMapping? FindNewInstanceMapping(TypeMappingKey mappingKey) => _newInstanceMappings.Find(mappingKey); - - public INewInstanceMapping? FindNewInstanceUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) => - _newInstanceMappings.FindUserMappingWithParameters(key, scope); + public INewInstanceMapping? FindNewInstanceMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) => + _newInstanceMappings.Find(mappingKey, scope); public INewInstanceUserMapping? FindNewInstanceUserMapping(IMethodSymbol method) => _newInstanceMappings.FindUserMapping(method); @@ -198,7 +196,7 @@ private class MappingCollectionInstance private readonly HashSet _explicitDefaultMappingKeys = []; /// - /// All mapping keys for which was called and returned a non-null result. + /// All mapping keys for which was called and returned a non-null result. /// private readonly HashSet _usedMappingKeys = []; @@ -230,8 +228,11 @@ private class MappingCollectionInstance public TUserMapping? FindUserMapping(IMethodSymbol method) => _userMappingsByMethod.GetValueOrDefault(method); - public T? Find(TypeMappingKey mappingKey) + public T? Find(TypeMappingKey mappingKey, ParameterScope? scope = null) { + if (scope is { IsEmpty: false }) + return FindByParameters(mappingKey, scope); + if (_defaultMappings.TryGetValue(mappingKey, out var mapping)) { _usedMappingKeys.Add(mappingKey); @@ -252,7 +253,7 @@ private class MappingCollectionInstance return mapping; } - public TUserMapping? FindUserMappingWithParameters(TypeMappingKey key, ParameterScope scope) + private TUserMapping? FindByParameters(TypeMappingKey key, ParameterScope scope) { foreach (var mapping in _userMappings) { From e915ac6539633d18c963ea02540712a1aa75d6e0 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sun, 22 Mar 2026 21:30:23 +0100 Subject: [PATCH 20/32] refactor: move ParameterScope.MarkUsed into Find and remove FindParameterizedUserMapping Mark parameters as used directly in FindByParameters, eliminating the need for a separate FindParameterizedUserMapping wrapper. This simplifies FindOrBuildMapping and FindOrBuildLooseNullableMapping cascades. --- .../Descriptors/MappingBuilderContext.cs | 23 ++----------------- .../Descriptors/MappingCollection.cs | 1 + 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index f41eef6d6c..448ccef459 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -70,19 +70,6 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => ? new ParameterScope(pm.AdditionalSourceParameters) : ParameterScope.Empty; - private INewInstanceMapping? FindParameterizedUserMapping(TypeMappingKey key) - { - if (ParameterScope.IsEmpty) - return null; - - var mapping = MappingBuilder.Find(key, ParameterScope); - if (mapping == null) - return null; - - ParameterScope.TryUseParameters(mapping); - return mapping; - } - public TypeMappingKey MappingKey { get; } public ITypeSymbol Source => MappingKey.Source; @@ -192,8 +179,7 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => Location? diagnosticLocation = null ) { - return FindParameterizedUserMapping(mappingKey) - ?? FindMapping(mappingKey) + return FindMapping(mappingKey, ParameterScope) ?? FindMapping(mappingKey.TargetNonNullable()) ?? BuildMapping(mappingKey, options, diagnosticLocation); } @@ -215,12 +201,7 @@ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) => Location? diagnosticLocation = null ) { - // Check parameterized user mappings first to ensure MarkUsed is called. - // Skip for expression contexts — expressions need the inlining path, not method calls. - if (!IsExpression && FindParameterizedUserMapping(key) is { } parameterizedMapping) - return parameterizedMapping; - - if (FindMapping(key) is INewInstanceUserMapping mapping) + if (FindMapping(key, ParameterScope) is INewInstanceUserMapping mapping) return mapping; // if a user mapping is referenced diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 9cf3c7737a..0ea6506daf 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -264,6 +264,7 @@ private class MappingCollectionInstance && scope.CanMatchParameters(pm.AdditionalSourceParameters) ) { + scope.MarkUsed(pm.AdditionalSourceParameters); return mapping; } } From 5bbcba75ac137d81f2035edd53b3c2fedbfbcb63 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sun, 22 Mar 2026 22:38:24 +0100 Subject: [PATCH 21/32] refactor: index user mappings by type pair with ListDictionary for efficient parameterized lookup --- .../Descriptors/MappingCollection.cs | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 0ea6506daf..09d8353169 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -175,10 +175,10 @@ private class MappingCollectionInstance private readonly Dictionary _userMappingsByMethod = new(SymbolEqualityComparer.Default); /// - /// All user mappings registered in this instance. + /// All user mappings registered in this instance, indexed by source/target type pair. /// Used as the canonical source for parameterized and diagnostic queries. /// - private readonly List _userMappings = []; + private readonly ListDictionary _userMappings = new(); /// /// Named mappings by their names. @@ -220,9 +220,10 @@ private class MappingCollectionInstance /// public IEnumerable UsedDuplicatedNonDefaultNonReferencedUserMappings => _userMappings - .Where(m => !m.IsExternal && !m.Default.HasValue) - .GroupBy(m => new TypeMappingKey(m)) - .Where(g => g.Count() > 1 && _usedMappingKeys.Contains(g.Key) && !_explicitDefaultMappingKeys.Contains(g.Key)) + .AsDictionary() + .Where(g => _usedMappingKeys.Contains(g.Key) && !_explicitDefaultMappingKeys.Contains(g.Key)) + .Select(g => g.Value.Where(m => !m.IsExternal && !m.Default.HasValue)) + .Where(g => g.Count() > 1) .SelectMany(g => g.Skip(1)) .Where(m => !_referencedNamedMappings.Contains(m)); @@ -255,18 +256,12 @@ private class MappingCollectionInstance private TUserMapping? FindByParameters(TypeMappingKey key, ParameterScope scope) { - foreach (var mapping in _userMappings) + var candidates = _userMappings.GetOrEmpty(key).Where(m => m is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 }); + + foreach (var mapping in candidates) { - if ( - mapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm - && SymbolEqualityComparer.IncludeNullability.Equals(key.Source, mapping.SourceType) - && SymbolEqualityComparer.IncludeNullability.Equals(key.Target, mapping.TargetType) - && scope.CanMatchParameters(pm.AdditionalSourceParameters) - ) - { - scope.MarkUsed(pm.AdditionalSourceParameters); + if (scope.TryUseParameters(mapping)) return mapping; - } } return default; @@ -304,7 +299,7 @@ public MappingCollectionAddResult TryAddAsDefault(T mapping, TypeMappingConfigur public MappingCollectionAddResult AddUserMapping(TUserMapping mapping, bool? isDefault, string? name) { AddNamedUserMapping(name, mapping); - _userMappings.Add(mapping); + _userMappings.Add(new TypeMappingKey(mapping), mapping); return isDefault switch { From c829a0ad72220080dae2caaaf8ee4c1da955258b Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sun, 22 Mar 2026 22:46:57 +0100 Subject: [PATCH 22/32] refactor: eliminate duplicate GetAllDirectlyAccessibleMethods query in SourceValueBuilder --- .../MappingBodyBuilders/SourceValueBuilder.cs | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index c874e309b0..a370e16e35 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -3,7 +3,6 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Configuration; -using Riok.Mapperly.Configuration.MethodReferences; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.MemberMappings; @@ -160,20 +159,25 @@ private static bool TryBuildMethodProvidedSourceValue( var methodReferenceConfiguration = memberMappingInfo.ValueConfiguration!.Use!; var targetSymbol = methodReferenceConfiguration.GetTargetType(ctx.BuilderContext); var scope = ctx.BuilderContext.ParameterScope; - var namedMethodCandidates = targetSymbol is null + var allNamedMethods = targetSymbol is null ? [] : ctx .BuilderContext.SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) .Where(m => m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false } - && scope.CanMatchParameters(m) && ctx.BuilderContext.AttributeAccessor.IsMappingNameEqualTo(m, methodReferenceConfiguration.Name) ) .ToList(); + var namedMethodCandidates = allNamedMethods.Where(m => scope.CanMatchParameters(m)).ToList(); + if (namedMethodCandidates.Count == 0) { - ReportMethodNotFoundDiagnostic(ctx.BuilderContext, targetSymbol, methodReferenceConfiguration); + var descriptor = + allNamedMethods.Count > 0 + ? DiagnosticDescriptors.MapValueMethodParametersUnsatisfied + : DiagnosticDescriptors.MapValueReferencedMethodNotFound; + ctx.BuilderContext.ReportDiagnostic(descriptor, methodReferenceConfiguration.FullName); sourceValue = null; return false; } @@ -215,29 +219,4 @@ private static bool TryBuildMethodProvidedSourceValue( ); return true; } - - private static void ReportMethodNotFoundDiagnostic( - MappingBuilderContext builderCtx, - ITypeSymbol? targetSymbol, - IMethodReferenceConfiguration methodRef - ) - { - // Check if a method by name exists but with unsatisfiable parameters - if ( - targetSymbol is not null - && builderCtx - .SymbolAccessor.GetAllDirectlyAccessibleMethods(targetSymbol) - .Any(m => - m is { IsAsync: false, ReturnsVoid: false, IsGenericMethod: false, Parameters.Length: > 0 } - && builderCtx.AttributeAccessor.IsMappingNameEqualTo(m, methodRef.Name) - ) - ) - { - builderCtx.ReportDiagnostic(DiagnosticDescriptors.MapValueMethodParametersUnsatisfied, methodRef.FullName); - } - else - { - builderCtx.ReportDiagnostic(DiagnosticDescriptors.MapValueReferencedMethodNotFound, methodRef.FullName); - } - } } From f9fa7559d721b143aa095e0810f0144b3237fdba Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 16:46:52 +0300 Subject: [PATCH 23/32] refactor: rename EmptyParameters to _emptyParameters per naming convention --- src/Riok.Mapperly/Descriptors/ParameterScope.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index 40243e1ed0..1cef0a7d12 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -6,7 +6,7 @@ namespace Riok.Mapperly.Descriptors; public class ParameterScope { - private static readonly IReadOnlyDictionary EmptyParameters = new Dictionary(); + private static readonly IReadOnlyDictionary _emptyParameters = new Dictionary(); private readonly ParameterScope? _parent; private readonly IReadOnlyDictionary _parameters; @@ -18,7 +18,7 @@ public ParameterScope(IReadOnlyCollection parameters) { if (parameters.Count == 0) { - _parameters = EmptyParameters; + _parameters = _emptyParameters; return; } From d0b1a7336c53f9c20ef8ac4b3c69e1206baa0d6f Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 16:47:13 +0300 Subject: [PATCH 24/32] refactor: use Skip(1).Any() instead of Count() > 1 to avoid full enumeration --- src/Riok.Mapperly/Descriptors/MappingCollection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs index 09d8353169..a271e04a29 100644 --- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs +++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs @@ -223,7 +223,7 @@ private class MappingCollectionInstance .AsDictionary() .Where(g => _usedMappingKeys.Contains(g.Key) && !_explicitDefaultMappingKeys.Contains(g.Key)) .Select(g => g.Value.Where(m => !m.IsExternal && !m.Default.HasValue)) - .Where(g => g.Count() > 1) + .Where(g => g.Skip(1).Any()) .SelectMany(g => g.Skip(1)) .Where(m => !_referencedNamedMappings.Contains(m)); From c5006a45620bf815acf6db56a10892bf5ded9624 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 16:48:05 +0300 Subject: [PATCH 25/32] refactor: rename test to describe expected state without implying change --- .../Mapping/UserMethodAdditionalParameterForwardingTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index eb1bc65b01..222ae69f01 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -57,7 +57,7 @@ public void MapValueUseMethodWithMultipleAdditionalParameters() } [Fact] - public void MapValueUseMethodWithZeroParamsStillWorks() + public void MapValueUseMethodWithZeroParamsWorks() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ From 91b20cf8c782e988a376660de716b7cd6c4a1d44 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 16:56:55 +0300 Subject: [PATCH 26/32] refactor: make CanMatchParameters(IReadOnlyCollection) private --- src/Riok.Mapperly/Descriptors/ParameterScope.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index 1cef0a7d12..f6834bd000 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -45,7 +45,7 @@ public ParameterScope(ParameterScope parent) /// /// Checks if all requested additional parameters can be satisfied by this scope (by normalized name). /// - public bool CanMatchParameters(IReadOnlyCollection requested) => + private bool CanMatchParameters(IReadOnlyCollection requested) => requested.All(p => _parameters.ContainsKey(p.NormalizedName)); /// From ddbfc6fe097e78193bfa9ffb785cf1327b56927a Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 16:59:25 +0300 Subject: [PATCH 27/32] refactor: centralize TrimStart('@') into MethodParameter.NormalizeName static helper --- .../Descriptors/Mappings/TypeMappingBuildContext.cs | 2 +- src/Riok.Mapperly/Descriptors/ParameterScope.cs | 7 +++---- src/Riok.Mapperly/Symbols/MethodParameter.cs | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs b/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs index ecf3a51491..acda3ed1b9 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs @@ -133,7 +133,7 @@ public TypeMappingBuildContext WithRefHandler(ExpressionSyntax refHandler) => yield return targetArgument.Value; else if (referenceHandlerParameter is not null && param.Ordinal == referenceHandlerParameter.Value.Ordinal) yield return referenceHandlerParameter.Value.WithArgument(refHandler); - else if (additionalParams?.TryGetValue(param.Name.TrimStart('@'), out var expr) == true) + else if (additionalParams?.TryGetValue(MethodParameter.NormalizeName(param.Name), out var expr) == true) yield return new MethodParameter(param, param.Type).WithArgument(expr); } } diff --git a/src/Riok.Mapperly/Descriptors/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs index f6834bd000..ef187bb083 100644 --- a/src/Riok.Mapperly/Descriptors/ParameterScope.cs +++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs @@ -51,7 +51,8 @@ private bool CanMatchParameters(IReadOnlyCollection requested) /// /// Checks if all parameters of a method can be satisfied by this scope (by normalized name). /// - public bool CanMatchParameters(IMethodSymbol method) => method.Parameters.All(p => _parameters.ContainsKey(NormalizeName(p.Name))); + public bool CanMatchParameters(IMethodSymbol method) => + method.Parameters.All(p => _parameters.ContainsKey(MethodParameter.NormalizeName(p.Name))); /// /// If the mapping is parameterized, checks if all its additional parameters can be @@ -82,7 +83,7 @@ public void MarkUsed(string name) return; } - _usedParameters?.Add(NormalizeName(name)); + _usedParameters?.Add(MethodParameter.NormalizeName(name)); } /// @@ -107,8 +108,6 @@ public void MarkUsed(IEnumerable names) } } - private static string NormalizeName(string name) => name.TrimStart('@'); - /// /// Returns parameter names that were never consumed by any consumer (for diagnostics). /// diff --git a/src/Riok.Mapperly/Symbols/MethodParameter.cs b/src/Riok.Mapperly/Symbols/MethodParameter.cs index cdfb6a0597..a669deae3a 100644 --- a/src/Riok.Mapperly/Symbols/MethodParameter.cs +++ b/src/Riok.Mapperly/Symbols/MethodParameter.cs @@ -16,7 +16,9 @@ public MethodParameter(IParameterSymbol symbol, ITypeSymbol parameterType) /// /// The parameter name with the verbatim identifier prefix (@) removed. /// - public string NormalizedName => Name.TrimStart('@'); + public string NormalizedName => NormalizeName(Name); + + public static string NormalizeName(string name) => name.TrimStart('@'); public MethodArgument WithArgument(ExpressionSyntax? argument) => new(this, argument ?? throw new ArgumentNullException(nameof(argument))); From 274e4632d00c5e3e15bab30bb2212410b2cb8c74 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 17:00:27 +0300 Subject: [PATCH 28/32] docs: split queryable projections example into tabs and fix formatting --- .../additional-mapping-parameters.mdx | 105 +++++++++++------- .../constant-generated-values.mdx | 1 + 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/docs/docs/configuration/additional-mapping-parameters.mdx b/docs/docs/configuration/additional-mapping-parameters.mdx index 07f55ebf62..0fb6cfe2c2 100644 --- a/docs/docs/configuration/additional-mapping-parameters.mdx +++ b/docs/docs/configuration/additional-mapping-parameters.mdx @@ -14,7 +14,7 @@ An additional mapping parameter has lower priority than a `MapProperty` mapping, but higher than a by-name matched regular member mapping. - + ```csharp [Mapper] public partial class CarMapper @@ -39,7 +39,7 @@ but higher than a by-name matched regular member mapping. ``` - + ```csharp [Mapper] public partial class CarMapper @@ -55,7 +55,7 @@ but higher than a by-name matched regular member mapping. target.Name = name; // highlight-end return target; - } + } } ``` @@ -72,7 +72,7 @@ Mapperly will **not** auto-generate mapping methods with additional parameters. Only explicitly declared user-defined methods are considered. - + ```csharp [Mapper] public partial class OrderMapper @@ -85,15 +85,19 @@ Only explicitly declared user-defined methods are considered. } ``` - + ```csharp - public partial OrderDto Map(Order source, int currentUserId) + [Mapper] + public partial class OrderMapper { - var target = new OrderDto(); - // highlight-start - target.Item = MapItem(source.Item, currentUserId); - // highlight-end - return target; + public partial OrderDto Map(Order source, int currentUserId) + { + var target = new OrderDto(); + // highlight-start + target.Item = MapItem(source.Item, currentUserId); + // highlight-end + return target; + } } ``` @@ -105,7 +109,7 @@ Additional parameters can be passed to methods referenced by `MapValue(Use = ... The method's parameters are matched by name from the mapping's additional parameters. - + ```csharp [Mapper] public partial class OrderMapper @@ -120,16 +124,21 @@ The method's parameters are matched by name from the mapping's additional parame // highlight-end } ``` + - + ```csharp - public partial OrderDto Map(Order source, decimal taxRate) + [Mapper] + public partial class OrderMapper { - var target = new OrderDto(); - // highlight-start - target.Total = ComputeTotal(taxRate); - // highlight-end - return target; + public partial OrderDto Map(Order source, decimal taxRate) + { + var target = new OrderDto(); + // highlight-start + target.Total = ComputeTotal(taxRate); + // highlight-end + return target; + } } ``` @@ -148,25 +157,43 @@ The parameters become captured variables in the generated projection lambda. User-implemented methods with additional parameters are inlined following the same rules as regular [user-implemented mapping methods in projections](./queryable-projections.mdx#user-implemented-mapping-methods). -```csharp -[Mapper] -public static partial class OrderMapper -{ - // highlight-start - public static partial IQueryable ProjectToDto( - this IQueryable q, - int currentUserId - ); - // highlight-end -} - -// Generated: -// source.Select(x => new OrderDto() -// { -// ... -// CurrentUserId = currentUserId, // captured from outer scope -// }); -``` + + + ```csharp + [Mapper] + public static partial class OrderMapper + { + public static partial IQueryable ProjectToDto( + this IQueryable q, + // highlight-next-line + int currentUserId + ); + } + ``` + + + ```csharp + [Mapper] + public static partial class OrderMapper + { + public static partial IQueryable ProjectToDto( + this IQueryable q, + // highlight-next-line + int currentUserId + ) + { + return q.Select(x => new OrderDto() + { + // ... + // highlight-start + CurrentUserId = currentUserId, // captured from outer scope + // highlight-end + }); + } + } + ``` + + :::info Mappings with additional parameters do have some limitations: @@ -174,7 +201,7 @@ Mappings with additional parameters do have some limitations: - Additional parameters are forwarded to user-defined nested mapping methods only (not auto-generated ones). - A mapping with additional mapping parameters cannot be the default mapping (it is not used by Mapperly when encountering a nested mapping for the given types), - see also [default mapping methods](./user-implemented-methods.mdx##default-mapping-methods). + see also [default mapping methods](./user-implemented-methods.mdx#default-mapping-methods). - Generic and runtime target type mappings do not support additional type parameters. - Derived type mappings do not support additional type parameters. ::: diff --git a/docs/docs/configuration/constant-generated-values.mdx b/docs/docs/configuration/constant-generated-values.mdx index 18fd75336c..cf7528c278 100644 --- a/docs/docs/configuration/constant-generated-values.mdx +++ b/docs/docs/configuration/constant-generated-values.mdx @@ -74,6 +74,7 @@ The method's parameters are matched by name from the mapping's additional parame target.FormattedDate = FormatDate(dateFormat); ``` + ### Named mapping From 5f8de3d7a8e85b64b7d32559a401ebb092601921 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 17:06:40 +0300 Subject: [PATCH 29/32] refactor: optimize inline expression parameter lookup with ContainsKey fast path --- .../UserImplementedInlinedExpressionMapping.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs index 72d717aa23..288a40c847 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs @@ -120,12 +120,12 @@ public override SyntaxNode VisitParenthesizedLambdaExpression(ParenthesizedLambd } // replace additional parameters with their corresponding expressions from the context - // Note: ctx.AdditionalParameters uses case-insensitive keys (for descriptor-phase matching), - // but syntax replacement must be case-sensitive to avoid replacing unrelated identifiers - // (e.g., property name CurrentUserId vs parameter currentUserId). - if (ctx.AdditionalParameters != null) + // The dictionary uses case-insensitive keys, so ContainsKey serves as a fast negative check. + // We then verify exact case to avoid replacing unrelated identifiers + // (e.g., property CurrentUserId vs parameter currentUserId). + if (ctx.AdditionalParameters is { } additionalParams && additionalParams.ContainsKey(node.Identifier.Text)) { - foreach (var (key, value) in ctx.AdditionalParameters) + foreach (var (key, value) in additionalParams) { if (string.Equals(key, node.Identifier.Text, StringComparison.Ordinal)) return value.WithTriviaFrom(node); From 445b165015547ba4f80d06a9497e0f55c7564b75 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 18:12:34 +0300 Subject: [PATCH 30/32] feat: add RMG098 diagnostic for case-insensitive duplicate additional parameter names --- .../analyzer-diagnostics/RMG098.mdx | 49 +++++++++++++++++++ .../analyzer-diagnostics/RMG099.mdx | 36 ++++++++++++++ src/Riok.Mapperly/AnalyzerReleases.Shipped.md | 1 + .../UserMappingMethodParameterExtractor.cs | 15 +++++- .../Diagnostics/DiagnosticDescriptors.cs | 10 ++++ ...MethodAdditionalParameterForwardingTest.cs | 21 ++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 docs/docs/configuration/analyzer-diagnostics/RMG098.mdx create mode 100644 docs/docs/configuration/analyzer-diagnostics/RMG099.mdx diff --git a/docs/docs/configuration/analyzer-diagnostics/RMG098.mdx b/docs/docs/configuration/analyzer-diagnostics/RMG098.mdx new file mode 100644 index 0000000000..334bbc043f --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/RMG098.mdx @@ -0,0 +1,49 @@ +--- +sidebar_label: RMG098 +description: 'Mapperly analyzer diagnostic RMG098 — Named mapping additional parameters cannot be satisfied' +--- + +# RMG098 — Named mapping additional parameters cannot be satisfied + +A named mapping referenced by `MapProperty(Use = ...)` or `MapPropertyFromSource(Use = ...)` +has [additional parameters](../additional-mapping-parameters.mdx) that cannot be matched +from the caller's additional parameters. + +To fix this, ensure the calling mapping method declares additional parameters with names matching +the parameters of the referenced named mapping. + +## Example + +`Transform` requires an `int multiplier` parameter, +but the `Map` method does not declare a matching additional parameter. + +```csharp +[Mapper] +public partial class MyMapper +{ + // highlight-start + // Error: Transform requires 'multiplier' but Map has no additional parameters + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + // highlight-end + public partial B Map(A source); + + [UserMapping(Default = false)] + private partial int Transform(int value, int multiplier); +} +``` + +Add the missing parameter to the calling mapping method: + +```csharp +[Mapper] +public partial class MyMapper +{ + [MapProperty(nameof(A.Value), nameof(B.Result), Use = nameof(Transform))] + // highlight-start + public partial B Map(A source, int multiplier); + // highlight-end + + [UserMapping(Default = false)] + private partial int Transform(int value, int multiplier); +} +``` diff --git a/docs/docs/configuration/analyzer-diagnostics/RMG099.mdx b/docs/docs/configuration/analyzer-diagnostics/RMG099.mdx new file mode 100644 index 0000000000..4af2db5d55 --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/RMG099.mdx @@ -0,0 +1,36 @@ +--- +sidebar_label: RMG099 +description: 'Mapperly analyzer diagnostic RMG099 — Duplicate additional parameter names differing only in casing' +--- + +# RMG099 — Duplicate additional parameter names differing only in casing + +A mapping method has multiple [additional parameters](../additional-mapping-parameters.mdx) +whose names differ only in casing (e.g., `userId` and `UserId`). +Mapperly matches additional parameters case-insensitively, +so only the first parameter is used and the others are ignored. + +## Example + +```csharp +[Mapper] +public partial class MyMapper +{ + // highlight-start + // Error: UserId and userId differ only in casing + public partial B Map(A source, int UserId, int userId); + // highlight-end +} +``` + +Rename the parameters to have distinct names or delete duplicate ones: + +```csharp +[Mapper] +public partial class MyMapper +{ + // highlight-start + public partial B Map(A source, int userId); + // highlight-end +} +``` diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index b18476a711..bf07bde190 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -224,3 +224,4 @@ RMG095 | Mapper | Warning | Invalid MSBuild configuration option RMG096 | Mapper | Hidden | A MapperIgnore* attribute does not specify a justification RMG097 | Mapper | Error | MapValue Use method parameters cannot be satisfied RMG098 | Mapper | Error | Named mapping additional parameters cannot be satisfied +RMG099 | Mapper | Error | Duplicate additional parameter names differing only in casing diff --git a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs index 53169bb48b..cc1b589039 100644 --- a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs @@ -57,7 +57,20 @@ p.Type.TypeKind is TypeKind.TypeParameter or TypeKind.Error } var additionalParameters = additionalParameterSymbols.Select(p => ctx.SymbolAccessor.WrapMethodParameter(p)).ToList(); - parameters = new MappingMethodParameters(sourceParameter.Value, targetParameter, refHandlerParameter, additionalParameters); + + // detect and deduplicate case-insensitive duplicate additional parameter names (e.g., int UserId, int userId) + var parameterGroups = additionalParameters.ToLookup(p => p.NormalizedName, StringComparer.OrdinalIgnoreCase); + foreach (var group in parameterGroups.Where(g => g.Count() > 1)) + { + ctx.ReportDiagnostic( + DiagnosticDescriptors.DuplicateAdditionalParameterCaseInsensitive, + method, + string.Join(", ", group.Select(p => p.Name)) + ); + } + + var dedupedAdditionalParameters = parameterGroups.Select(g => g.First()).ToList(); + parameters = new MappingMethodParameters(sourceParameter.Value, targetParameter, refHandlerParameter, dedupedAdditionalParameters); return true; } diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 4eb537a172..b59eb3232d 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -838,6 +838,16 @@ public static class DiagnosticDescriptors helpLinkUri: BuildHelpUri("RMG098") ); + public static readonly DiagnosticDescriptor DuplicateAdditionalParameterCaseInsensitive = new( + "RMG099", + "Duplicate additional parameter names differing only in casing", + "The additional parameters {0} have names that differ only in casing, only the first one is used", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true, + helpLinkUri: BuildHelpUri("RMG099") + ); + private static string BuildHelpUri(string id) { #if ENV_NEXT diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs index 222ae69f01..de4c7a362c 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs @@ -876,4 +876,25 @@ public void ExternalStaticMapperMethodWithAdditionalParameter() """ ); } + + [Fact] + public void DuplicateAdditionalParameterNamesCaseInsensitiveShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + partial B Map(A src, int UserId, int userId); + """, + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } public int UserId { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.DuplicateAdditionalParameterCaseInsensitive, + "The additional parameters UserId, userId have names that differ only in casing, only the first one is used" + ) + .HaveAssertedAllDiagnostics(); + } } From fd3abffb14b3e7601bc879f15aa6b0c2eab9c75e Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Thu, 9 Apr 2026 18:12:45 +0300 Subject: [PATCH 31/32] test: add integration test for additional parameter inlining in projections --- .../AdditionalParameterInliningMapperTest.cs | 22 +++++++++++++++++++ .../AdditionalParameterInliningMapper.cs | 18 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 test/Riok.Mapperly.IntegrationTests/AdditionalParameterInliningMapperTest.cs create mode 100644 test/Riok.Mapperly.IntegrationTests/Mapper/AdditionalParameterInliningMapper.cs diff --git a/test/Riok.Mapperly.IntegrationTests/AdditionalParameterInliningMapperTest.cs b/test/Riok.Mapperly.IntegrationTests/AdditionalParameterInliningMapperTest.cs new file mode 100644 index 0000000000..757f076476 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/AdditionalParameterInliningMapperTest.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; +using Riok.Mapperly.IntegrationTests.Mapper; +using Riok.Mapperly.IntegrationTests.Models; +using Shouldly; +using Xunit; + +namespace Riok.Mapperly.IntegrationTests +{ + public class AdditionalParameterInliningMapperTest + { + [Fact] + public void ProjectWithAdditionalParameterShouldInline() + { + var objects = new List { new() { IdValue = 42 } }.AsQueryable(); + var result = objects.ProjectWithAdditionalParameter(100).ToList(); + result.ShouldHaveSingleItem(); + result[0].IdValue.ShouldBe(42); + result[0].ValueFromParameter.ShouldBe(100); + } + } +} diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/AdditionalParameterInliningMapper.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/AdditionalParameterInliningMapper.cs new file mode 100644 index 0000000000..81dbe17b94 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/Mapper/AdditionalParameterInliningMapper.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.IntegrationTests.Dto; +using Riok.Mapperly.IntegrationTests.Models; + +namespace Riok.Mapperly.IntegrationTests.Mapper +{ + [Mapper] + public static partial class AdditionalParameterInliningMapper + { + private static partial AdditionalParametersDto MapToDto(IdObject source, int valueFromParameter); + + public static partial IQueryable ProjectWithAdditionalParameter( + this IQueryable q, + int valueFromParameter + ); + } +} From 69dbdde5534660132c148f9ed51178a0fd4ff0c1 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Fri, 10 Apr 2026 10:33:03 +0300 Subject: [PATCH 32/32] refactor: optimize Linq Count call --- .../Descriptors/UserMappingMethodParameterExtractor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs index cc1b589039..a94fda942d 100644 --- a/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs @@ -60,7 +60,7 @@ p.Type.TypeKind is TypeKind.TypeParameter or TypeKind.Error // detect and deduplicate case-insensitive duplicate additional parameter names (e.g., int UserId, int userId) var parameterGroups = additionalParameters.ToLookup(p => p.NormalizedName, StringComparer.OrdinalIgnoreCase); - foreach (var group in parameterGroups.Where(g => g.Count() > 1)) + foreach (var group in parameterGroups.Where(g => g.Skip(1).Any())) { ctx.ReportDiagnostic( DiagnosticDescriptors.DuplicateAdditionalParameterCaseInsensitive,