diff --git a/docs/docs/configuration/additional-mapping-parameters.mdx b/docs/docs/configuration/additional-mapping-parameters.mdx
index 6643841abb..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,141 @@ but higher than a by-name matched regular member mapping.
target.Name = name;
// highlight-end
return target;
- }
+ }
+ }
+ ```
+
+
+
+## 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
+ [Mapper]
+ public partial class OrderMapper
+ {
+ 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
+ [Mapper]
+ public partial class OrderMapper
+ {
+ 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
+ {
+ 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
+ });
+ }
}
```
@@ -64,10 +198,10 @@ but higher than a by-name matched regular member mapping.
:::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).
+ 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/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/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/docs/docs/configuration/constant-generated-values.mdx b/docs/docs/configuration/constant-generated-values.mdx
index 5ad8f65efa..cf7528c278 100644
--- a/docs/docs/configuration/constant-generated-values.mdx
+++ b/docs/docs/configuration/constant-generated-values.mdx
@@ -54,6 +54,29 @@ 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..55874bdd17 100644
--- a/docs/docs/configuration/queryable-projections.mdx
+++ b/docs/docs/configuration/queryable-projections.mdx
@@ -122,6 +122,76 @@ 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).
+
+### 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.
diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
index d095375063..bf07bde190 100644
--- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
+++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md
@@ -222,3 +222,6 @@ 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
+RMG099 | Mapper | Error | Duplicate additional parameter names differing only in casing
diff --git a/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/InlineExpressionMappingBuilderContext.cs
index b61fb9ab41..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)
@@ -123,11 +123,10 @@ 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;
+ userMapping ??= MappingBuilder.Find(mappingKey, ParameterScope) as IUserMapping;
+
options &= ~MappingBuildingOptions.KeepUserSymbol;
return BuildMapping(userMapping, mappingKey, options, diagnosticLocation);
}
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/MemberMappingDiagnosticReporter.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs
index 39e54dc088..36a8dc6032 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MemberMappingDiagnosticReporter.cs
@@ -56,7 +56,11 @@ bool requiredMembersNeedToBeMapped
private static void AddUnmappedAdditionalSourceMembersDiagnostics(MappingBuilderContext ctx, MembersMappingState state)
{
- foreach (var name in state.UnmappedAdditionalSourceMemberNames)
+ // 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 70190b979f..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,15 +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
+ HashSet ignoredSourceMemberNames,
+ ParameterScope parameterScope
)
{
private readonly Dictionary _aliasedSourceMembers = new(StringComparer.OrdinalIgnoreCase);
@@ -39,16 +38,13 @@ HashSet ignoredSourceMemberNames
///
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 IReadOnlyCollection IgnoredSourceMemberNames => ignoredSourceMemberNames;
///
@@ -59,10 +55,12 @@ HashSet ignoredSourceMemberNames
///
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;
@@ -210,8 +208,7 @@ 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..632dd0bd65 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,8 +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 targetMembers = GetTargetMembers(ctx, mapping);
// build ignored members
@@ -58,27 +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
- );
- }
-
- private static IReadOnlyDictionary GetAdditionalSourceMembers(MappingBuilderContext ctx)
- {
- if (ctx.UserMapping is not MethodMapping { AdditionalSourceParameters.Count: > 0 } methodMapping)
- return _emptyAdditionalSourceMembers;
-
- return methodMapping.AdditionalSourceParameters.ToDictionary(
- x => x.Name.TrimStart('@'), // trim verbatim identifier prefix
- x => new ParameterSourceMember(x),
- StringComparer.OrdinalIgnoreCase
+ ignoredSourceMemberNames,
+ ctx.ParameterScope
);
}
diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs
index a6f666e0a5..a370e16e35 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs
@@ -158,22 +158,26 @@ private static bool TryBuildMethodProvidedSourceValue(
{
var methodReferenceConfiguration = memberMappingInfo.ValueConfiguration!.Use!;
var targetSymbol = methodReferenceConfiguration.GetTargetType(ctx.BuilderContext);
- var namedMethodCandidates = targetSymbol is null
+ var scope = ctx.BuilderContext.ParameterScope;
+ var allNamedMethods = 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 }
&& ctx.BuilderContext.AttributeAccessor.IsMappingNameEqualTo(m, methodReferenceConfiguration.Name)
)
.ToList();
+ var namedMethodCandidates = allNamedMethods.Where(m => scope.CanMatchParameters(m)).ToList();
+
if (namedMethodCandidates.Count == 0)
{
- ctx.BuilderContext.ReportDiagnostic(
- DiagnosticDescriptors.MapValueReferencedMethodNotFound,
- methodReferenceConfiguration.FullName
- );
+ var descriptor =
+ allNamedMethods.Count > 0
+ ? DiagnosticDescriptors.MapValueMethodParametersUnsatisfied
+ : DiagnosticDescriptors.MapValueReferencedMethodNotFound;
+ ctx.BuilderContext.ReportDiagnostic(descriptor, methodReferenceConfiguration.FullName);
sourceValue = null;
return false;
}
@@ -204,7 +208,15 @@ 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();
+ scope.MarkUsed(additionalParameterNames);
+
+ sourceValue = new MethodProvidedSourceValue(
+ methodSymbol.Name,
+ methodReferenceConfiguration.GetTargetName(ctx.BuilderContext),
+ additionalParameterNames
+ );
return true;
}
}
diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
index d72ea37f76..448ccef459 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs
@@ -56,12 +56,20 @@ 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.IsEmpty ? BuildParameterScope(userMapping) : new ParameterScope(ctx.ParameterScope);
if (ignoreDerivedTypes)
{
Configuration = Configuration with { DerivedTypes = [] };
}
}
+ private static ParameterScope BuildParameterScope(IUserMapping? userMapping) =>
+ userMapping is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 } pm
+ ? new ParameterScope(pm.AdditionalSourceParameters)
+ : ParameterScope.Empty;
+
public TypeMappingKey MappingKey { get; }
public ITypeSymbol Source => MappingKey.Source;
@@ -83,6 +91,8 @@ protected MappingBuilderContext(
///
public virtual bool IsExpression => false;
+ public ParameterScope ParameterScope { get; } = ParameterScope.Empty;
+
public InstanceConstructorFactory InstanceConstructors { get; }
///
@@ -123,7 +133,8 @@ protected MappingBuilderContext(
///
/// The mapping key.
/// The found mapping, or null if none is found.
- public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey) => MappingBuilder.Find(mappingKey);
+ public virtual INewInstanceMapping? FindMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) =>
+ MappingBuilder.Find(mappingKey, scope);
///
/// Tries to find an existing mapping for the provided types.
@@ -168,7 +179,7 @@ protected MappingBuilderContext(
Location? diagnosticLocation = null
)
{
- return FindMapping(mappingKey)
+ return FindMapping(mappingKey, ParameterScope)
?? FindMapping(mappingKey.TargetNonNullable())
?? BuildMapping(mappingKey, options, diagnosticLocation);
}
@@ -190,7 +201,7 @@ protected MappingBuilderContext(
Location? diagnosticLocation = null
)
{
- 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/MappingBuilders/InlineExpressionMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs
index 9490423bc1..b80dbf4130 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/InlineExpressionMappingBuilder.cs
@@ -22,8 +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);
- var userMapping = ctx.FindMapping(sourceType, targetType) as IUserMapping;
var inlineCtx = new InlineExpressionMappingBuilderContext(ctx, userMapping, mappingKey);
if (userMapping is UserImplementedMethodMapping && inlineCtx.FindMapping(sourceType, targetType) is { } inlinedUserMapping)
@@ -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/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs
index 0ca9844a1e..e4320dde35 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/MappingBuilder.cs
@@ -42,7 +42,8 @@ public class MappingBuilder(MappingCollection mappings, MapperDeclaration mapper
///
public IEnumerable ExistingTargetUserMappings => mappings.ExistingTargetUserMappings;
- public INewInstanceMapping? Find(TypeMappingKey mapping) => mappings.FindNewInstanceMapping(mapping);
+ 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/MappingBuilders/UseNamedMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs
index 262e1d5018..a873a56b55 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/UseNamedMappingBuilder.cs
@@ -20,6 +20,12 @@ public static class UseNamedMappingBuilder
return null;
}
+ 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);
@@ -49,6 +55,12 @@ public static class UseNamedMappingBuilder
if (existingTargetMapping is null)
return null;
+ if (!ctx.ParameterScope.TryUseParameters(existingTargetMapping))
+ {
+ ctx.ReportDiagnostic(DiagnosticDescriptors.NamedMappingParametersUnsatisfied, useNamedMapping);
+ return null;
+ }
+
var source = ctx.Source;
var target = ctx.Target;
diff --git a/src/Riok.Mapperly/Descriptors/MappingCollection.cs b/src/Riok.Mapperly/Descriptors/MappingCollection.cs
index 9eb169e648..a271e04a29 100644
--- a/src/Riok.Mapperly/Descriptors/MappingCollection.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingCollection.cs
@@ -57,11 +57,13 @@ 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? FindNewInstanceMapping(TypeMappingKey mappingKey, ParameterScope? scope = null) =>
+ _newInstanceMappings.Find(mappingKey, scope);
public INewInstanceUserMapping? FindNewInstanceUserMapping(IMethodSymbol method) => _newInstanceMappings.FindUserMapping(method);
@@ -172,6 +174,12 @@ private class MappingCollectionInstance
///
private readonly Dictionary _userMappingsByMethod = new(SymbolEqualityComparer.Default);
+ ///
+ /// 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 ListDictionary _userMappings = new();
+
///
/// Named mappings by their names.
///
@@ -188,13 +196,7 @@ 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.
+ /// All mapping keys for which was called and returned a non-null result.
///
private readonly HashSet _usedMappingKeys = [];
@@ -211,17 +213,27 @@ 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
+ .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.Skip(1).Any())
+ .SelectMany(g => g.Skip(1))
+ .Where(m => !_referencedNamedMappings.Contains(m));
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);
@@ -242,6 +254,19 @@ private class MappingCollectionInstance
return mapping;
}
+ private TUserMapping? FindByParameters(TypeMappingKey key, ParameterScope scope)
+ {
+ var candidates = _userMappings.GetOrEmpty(key).Where(m => m is IParameterizedMapping { AdditionalSourceParameters.Count: > 0 });
+
+ foreach (var mapping in candidates)
+ {
+ if (scope.TryUseParameters(mapping))
+ return mapping;
+ }
+
+ return default;
+ }
+
public void AddNamedUserMapping(string? name, TUserMapping mapping)
{
var isNewUserMappingMethod = _userMappingsByMethod.TryAdd(mapping.Method, mapping);
@@ -274,6 +299,7 @@ public MappingCollectionAddResult TryAddAsDefault(T mapping, TypeMappingConfigur
public MappingCollectionAddResult AddUserMapping(TUserMapping mapping, bool? isDefault, string? name)
{
AddNamedUserMapping(name, mapping);
+ _userMappings.Add(new TypeMappingKey(mapping), mapping);
return isDefault switch
{
@@ -287,41 +313,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/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/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/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/TypeMappingBuildContext.cs b/src/Riok.Mapperly/Descriptors/Mappings/TypeMappingBuildContext.cs
index ba9ef9934d..acda3ed1b9 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(MethodParameter.NormalizeName(param.Name), out var expr) == true)
+ yield return new MethodParameter(param, param.Type).WithArgument(expr);
+ }
+ }
+ }
}
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..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,
@@ -40,9 +50,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 +66,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 +80,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))
)
);
}
diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedInlinedExpressionMapping.cs
index 4e638e78ca..288a40c847 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
+ // 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 additionalParams)
+ {
+ if (string.Equals(key, node.Identifier.Text, StringComparison.Ordinal))
+ return value.WithTriviaFrom(node);
+ }
+ }
+
return base.VisitIdentifierName(node);
}
diff --git a/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserMappings/UserImplementedMethodMapping.cs
index a1d41c2970..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);
@@ -58,8 +67,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 +76,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/ParameterScope.cs b/src/Riok.Mapperly/Descriptors/ParameterScope.cs
new file mode 100644
index 0000000000..ef187bb083
--- /dev/null
+++ b/src/Riok.Mapperly/Descriptors/ParameterScope.cs
@@ -0,0 +1,116 @@
+using Microsoft.CodeAnalysis;
+using Riok.Mapperly.Descriptors.Mappings;
+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 (by normalized name).
+ ///
+ private bool CanMatchParameters(IReadOnlyCollection requested) =>
+ requested.All(p => _parameters.ContainsKey(p.NormalizedName));
+
+ ///
+ /// 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(MethodParameter.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).
+ /// Delegates to the root scope so usage tracking stays unified.
+ ///
+ public void MarkUsed(string name)
+ {
+ if (_parent != null)
+ {
+ _parent.MarkUsed(name);
+ return;
+ }
+
+ _usedParameters?.Add(MethodParameter.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);
+ }
+ }
+
+ ///
+ /// 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/Descriptors/UserMappingMethodParameterExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMappingMethodParameterExtractor.cs
index 034f5ea3b7..a94fda942d 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
@@ -64,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.Skip(1).Any()))
+ {
+ 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/Descriptors/UserMethodMappingExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs
index cf2bb536d3..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, false, 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;
diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
index f5c49ccc3a..b59eb3232d 100644
--- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
+++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
@@ -818,6 +818,36 @@ 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")
+ );
+
+ public static readonly DiagnosticDescriptor NamedMappingParametersUnsatisfied = new(
+ "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,
+ 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/src/Riok.Mapperly/Symbols/MethodParameter.cs b/src/Riok.Mapperly/Symbols/MethodParameter.cs
index 660bc7aa51..a669deae3a 100644
--- a/src/Riok.Mapperly/Symbols/MethodParameter.cs
+++ b/src/Riok.Mapperly/Symbols/MethodParameter.cs
@@ -13,6 +13,13 @@ 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 => NormalizeName(Name);
+
+ public static string NormalizeName(string name) => name.TrimStart('@');
+
public MethodArgument WithArgument(ExpressionSyntax? argument) =>
new(this, argument ?? throw new ArgumentNullException(nameof(argument)));
}
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
+ );
+ }
+}
diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs
index 463db8c487..0c7e193fe8 100644
--- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs
+++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionTest.cs
@@ -225,6 +225,151 @@ 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 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 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/Mapping/UserMethodAdditionalParameterForwardingTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs
new file mode 100644
index 0000000000..de4c7a362c
--- /dev/null
+++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParameterForwardingTest.cs
@@ -0,0 +1,900 @@
+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 MapValueUseMethodWithZeroParamsWorks()
+ {
+ 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 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 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()
+ {
+ 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()
+ {
+ 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();
+ }
+
+ [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();
+ }
+
+ [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);
+ """
+ );
+ }
+
+ [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 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()
+ {
+ 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;
+ """
+ );
+ }
+
+ [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();
+ }
+}
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.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
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
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