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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/docs/configuration/additional-mapping-parameters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,129 @@ Mappings with additional parameters do have some limitations:
- Generic and runtime target type mappings do not support additional type parameters.
- Derived type mappings do not support additional type parameters.
:::

## Map additional sources

Apply `[MapAdditionalSource]` to a parameter to merge its **properties** directly into the destination,
just like properties from the main source.
This is useful when you need to combine multiple source objects into a single destination type.

<Tabs>
<TabItem value="declaration" label="Declaration" default>
```csharp
public class SourceA
{
public string A { get; set; }
public string B { get; set; }
}

public class SourceB
{
public string C { get; set; }
public string D { get; set; }
}

public class Destination
{
public required string A { get; init; }
public required string B { get; init; }
public required string C { get; init; }
public required string D { get; init; }
}

[Mapper]
public partial class MyMapper
{
// highlight-start
public partial Destination Map(SourceA sourceA, [MapAdditionalSource] SourceB sourceB);
// highlight-end
}
```

</TabItem>
<TabItem value="generated" label="Generated code">
```csharp
public partial Destination Map(SourceA sourceA, SourceB sourceB)
{
var target = new Destination(
A: sourceA.A,
B: sourceA.B,
// highlight-start
C: sourceB.C,
D: sourceB.D
// highlight-end
);
return target;
}
```
</TabItem>
</Tabs>

Unlike a plain additional parameter — which maps the parameter value **itself** to a matching target member by name —
`[MapAdditionalSource]` maps the parameter's **members** to the destination.

### Priority

When multiple sources have a member with the same name, the first one wins:
main source > first `[MapAdditionalSource]` parameter > second `[MapAdditionalSource]` parameter > …

### Explicit member mappings

`[MapProperty]` works with additional sources.
Use the additional source member name directly as the source path — no need to prefix it with the parameter name.

```csharp
[MapProperty(nameof(SourceB.CustomName), nameof(Destination.MappedName))]
public partial Destination Map(SourceA sourceA, [MapAdditionalSource] SourceB sourceB);
```

### Nested properties

`[MapNestedProperties]` also works with additional source parameters:

```csharp
[MapNestedProperties(nameof(SourceB.Inner))]
public partial Destination Map(SourceA sourceA, [MapAdditionalSource] SourceB sourceB);
```

### Nullable additional sources

When the main source or any `[MapAdditionalSource]` parameter is nullable,
Mapperly guards against all of them together:

<Tabs>
<TabItem value="declaration" label="Declaration" default>
```csharp
public partial Destination? Map(SourceA? sourceA, [MapAdditionalSource] SourceB? sourceB);
```
</TabItem>
<TabItem value="generated" label="Generated code">
```csharp
public partial Destination? Map(SourceA? sourceA, SourceB? sourceB)
{
// highlight-start
if (sourceA == null || sourceB == null)
return default;
// highlight-end
var target = new Destination();
target.A = sourceA.A;
target.B = sourceB.B;
return target;
}
```
</TabItem>
</Tabs>

### Existing target mappings

`[MapAdditionalSource]` works with [existing target](./existing-target.mdx) (void) mappings too.
Place `[MappingTarget]` on the target parameter when additional source parameters are present
to help Mapperly identify which parameter is the destination:

```csharp
public partial void Map(SourceA sourceA, [MapAdditionalSource] SourceB sourceB, [MappingTarget] Destination target);
```

:::info
`[MapAdditionalSource]` cannot be applied to the source, target, or reference handler parameter (diagnostic [RMG100](../analyzer-diagnostics/RMG100)).
:::
36 changes: 36 additions & 0 deletions docs/docs/configuration/analyzer-diagnostics/RMG100.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
sidebar_label: RMG100
description: 'Mapperly analyzer diagnostic RMG100 — MapAdditionalSource attribute cannot be applied to this parameter'
---

# RMG100 — MapAdditionalSource attribute cannot be applied to this parameter

`[MapAdditionalSource]` was applied to a parameter that cannot act as an additional source:
the source parameter, the target parameter (in [existing target mappings](../existing-target.mdx)),
or the reference handler parameter.

## Example

```csharp
[Mapper]
public partial class MyMapper
{
// highlight-start
// Error: [MapAdditionalSource] on the source parameter has no effect
public partial Destination Map([MapAdditionalSource] SourceA sourceA, SourceB sourceB);
// highlight-end
}
```

Remove `[MapAdditionalSource]` from the source, target, or reference handler parameter.
Apply it only to the extra parameters you want to merge into the destination:

```csharp
[Mapper]
public partial class MyMapper
{
// highlight-start
public partial Destination Map(SourceA sourceA, [MapAdditionalSource] SourceB sourceB);
// highlight-end
}
```
11 changes: 11 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapAdditionalSourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Diagnostics;

namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Marks a parameter as an additional source for mapping.
/// Properties from this parameter will be available for mapping to the destination.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapAdditionalSourceAttribute : Attribute;
6 changes: 6 additions & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules

| Rule ID | Category | Severity | Notes |
|---------|----------|----------|-------------------------------------------------------------------|
| RMG100 | Mapper | Error | MapAdditionalSource attribute cannot be applied to this parameter |
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,23 @@ namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;
/// An abstract base implementation of <see cref="IMembersBuilderContext{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the mapping.</typeparam>
public abstract class MembersMappingBuilderContext<T>(MappingBuilderContext builderContext, T mapping) : IMembersBuilderContext<T>
public abstract class MembersMappingBuilderContext<T> : IMembersBuilderContext<T>
where T : IMapping
{
private readonly MembersMappingState _state = MembersMappingStateBuilder.Build(builderContext, mapping);
private readonly MembersMappingState _state;
private readonly NestedMappingsContext _nestedMappingsContext;

private readonly NestedMappingsContext _nestedMappingsContext = NestedMappingsContext.Create(builderContext);
protected MembersMappingBuilderContext(MappingBuilderContext builderContext, T mapping)
{
BuilderContext = builderContext;
Mapping = mapping;
_state = MembersMappingStateBuilder.Build(builderContext, mapping);
_nestedMappingsContext = NestedMappingsContext.Create(builderContext, _state.AdditionalSourceMembers);
}

public MappingBuilderContext BuilderContext { get; } = builderContext;
public MappingBuilderContext BuilderContext { get; }

public T Mapping { get; } = mapping;
public T Mapping { get; }

public void AddDiagnostics(bool requiredMembersNeedToBeMapped)
{
Expand Down Expand Up @@ -173,6 +180,22 @@ out sourceMemberPath
return true;
}

// search member properties of [MapAdditionalSource] parameters
var additionalSourceTypes = _state.AdditionalSourceMembers.Values.Where(m => m.IsSpecialAdditionalSource).Select(m => m.Type);
if (
BuilderContext.SymbolAccessor.TryFindMemberPath(
additionalSourceTypes,
pathCandidates,
_state.IgnoredSourceMemberNames,
ignoreCase,
out sourceMemberPath
)
)
{
sourcePath = new SourceMemberPath(sourceMemberPath, SourceMemberType.AdditionalMappingMethodParameter);
return true;
}

sourcePath = null;
return false;
}
Expand Down Expand Up @@ -234,23 +257,34 @@ private IEnumerable<MemberMappingInfo> ResolveMemberMappingInfo(IEnumerable<Memb

private bool ResolveMemberConfigSourcePath(MemberMappingConfiguration config, [NotNullWhen(true)] out SourceMemberPath? sourcePath)
{
if (!BuilderContext.SymbolAccessor.TryFindMemberPath(Mapping.SourceType, config.Source, out var sourceMemberPath))
if (BuilderContext.SymbolAccessor.TryFindMemberPath(Mapping.SourceType, config.Source, out var sourceMemberPath))
{
BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ConfiguredMappingSourceMemberNotFound,
config.Source.FullName,
Mapping.SourceType
);
sourcePath = new SourceMemberPath(sourceMemberPath, SourceMemberType.Member);
return true;
}

// consume this member config and prevent its further usage
// as it is invalid, and a diagnostic has already been reported
_state.ConsumeMemberConfig(config);
sourcePath = null;
return false;
// Try [MapAdditionalSource] parameter types when not found on the main source.
var additionalSourceTypes = _state.AdditionalSourceMembers.Values.Where(m => m.IsSpecialAdditionalSource).Select(m => m.Type);
foreach (var additionalType in additionalSourceTypes)
{
if (BuilderContext.SymbolAccessor.TryFindMemberPath(additionalType, config.Source, out sourceMemberPath))
{
sourcePath = new SourceMemberPath(sourceMemberPath, SourceMemberType.AdditionalMappingMethodParameter);
return true;
}
}

sourcePath = new SourceMemberPath(sourceMemberPath, SourceMemberType.Member);
return true;
BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ConfiguredMappingSourceMemberNotFound,
config.Source.FullName,
Mapping.SourceType
);

// consume this member config and prevent its further usage
// as it is invalid, and a diagnostic has already been reported
_state.ConsumeMemberConfig(config);
sourcePath = null;
return false;
}

private bool TryFindSourcePath(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ internal sealed class MembersMappingState(
Dictionary<string, List<MemberMappingConfiguration>> memberConfigsByRootTargetName,
Dictionary<string, List<IMemberPathConfiguration>> configuredTargetMembersByRootName,
HashSet<string> ignoredSourceMemberNames,
ParameterScope parameterScope
ParameterScope parameterScope,
HashSet<string>? additionalSourceNormalizedNames = null
)
{
private readonly Dictionary<string, IMappableMember> _aliasedSourceMembers = new(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -58,7 +59,7 @@ ParameterScope parameterScope
public IReadOnlyDictionary<string, IMappableMember> AdditionalSourceMembers =>
field ??= parameterScope.Parameters.Values.ToDictionary<MethodParameter, string, IMappableMember>(
x => x.NormalizedName,
x => new ParameterSourceMember(x),
x => new ParameterSourceMember(x, additionalSourceNormalizedNames?.Contains(x.NormalizedName) == true),
StringComparer.OrdinalIgnoreCase
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp
// For separate auto-generated method mappings, inherited parent parameters
// must not shadow source type members.
var parameterScope = ctx.ParameterScope.IsRoot || ctx.IsExpression ? ctx.ParameterScope : ParameterScope.Empty;
var additionalSourceNormalizedNames = GetAdditionalSourceNormalizedNames(ctx);

// [MapAdditionalSource] params contribute their properties directly to the mapping.
// Pre-mark them as used so RMG082 is not emitted for them.
if (additionalSourceNormalizedNames.Count > 0)
parameterScope.MarkUsed(additionalSourceNormalizedNames);

return new MembersMappingState(
unmappedSourceMemberNames,
Expand All @@ -68,10 +74,19 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp
memberConfigsByRootTargetName,
configuredTargetMembersByRootName.AsDictionary(),
ignoredSourceMemberNames,
parameterScope
parameterScope,
additionalSourceNormalizedNames
);
}

private static HashSet<string> GetAdditionalSourceNormalizedNames(MappingBuilderContext ctx)
{
if (ctx.UserMapping is not MethodMapping { AdditionalSourceMergeParameters.Count: > 0 } methodMapping)
return [];

return methodMapping.AdditionalSourceMergeParameters.Select(p => p.NormalizedName).ToHashSet(StringComparer.OrdinalIgnoreCase);
}

private static HashSet<string> GetSourceMemberNames(MappingBuilderContext ctx, IMapping mapping)
{
return ctx.SymbolAccessor.GetAllAccessibleMappableMembers(mapping.SourceType).Select(x => x.Name).ToHashSet();
Expand Down
Loading
Loading