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