Skip to content

Commit

Permalink
feat: Map from source object (#1247)
Browse files Browse the repository at this point in the history
Adds a MapPropertyFromSourceAttribute, that allows using the source object directly for mappings.
---------

Co-authored-by: Rhodon <rhodonvantilburg@gmail.com>
  • Loading branch information
rhodon-jargon and Rhodon authored May 3, 2024
1 parent c773724 commit d790055
Show file tree
Hide file tree
Showing 55 changed files with 632 additions and 177 deletions.
38 changes: 38 additions & 0 deletions docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ public partial class CarMapper
}
```

#### Mapping from source

The source object itself can be mapped to a property of the target object with the `MapPropertyFromSource` attribute:

```csharp
[Mapper]
public partial class CarMapper
{
// highlight-start
[MapPropertyFromSource(nameof(CarChanges.OriginalCar))]
// highlight-end
public partial CarChanges ToChanges(Car car);
}
```

### Ignore properties / fields

To ignore a property or field, the `MapperIgnoreTargetAttribute` or `MapperIgnoreSourceAttribute` can be used.
Expand Down Expand Up @@ -375,3 +390,26 @@ See also [user-implemented mapping methods](./user-implemented-methods.mdx).

</TabItem>
</Tabs>

#### User-implemented mapping from source

The `Use` property can also be assigned on the `MapPropertyFromSource` attribute, for example to access multiple properties of the source in the mapping method:

```csharp
[Mapper]
public partial class CarMapper
{
// highlight-start
[MapPropertyFromSource(nameof(CarDto.NetPrice), Use = nameof(MapPrice))]
// highlight-end
public partial CarDto MapCar(Car source);

// highlight-start
private decimal MapPrice(Car car)
=> car.Price - car.Discount;
// highlight-end
// generates
target.NetPrice = MapPrice(source);
}
```
57 changes: 57 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapPropertyFromSourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Diagnostics;

namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Maps a property from the source object.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapPropertyFromSourceAttribute : Attribute
{
private const string PropertyAccessSeparatorStr = ".";
private const char PropertyAccessSeparator = '.';

/// <summary>
/// Maps the specified target property from the source object.
/// </summary>
/// <param name="target">The name of the target property. The use of `nameof()` is encouraged. A path can be specified by joining property names with a '.'.</param>
public MapPropertyFromSourceAttribute(string target)
: this(target.Split(PropertyAccessSeparator)) { }

/// <summary>
/// Maps the specified target property from the source object.
/// </summary>
/// <param name="target">The path of the target property. The use of `nameof()` is encouraged.</param>
public MapPropertyFromSourceAttribute(string[] target)
{
Target = target;
}

/// <summary>
/// Gets the name of the target property.
/// </summary>
public IReadOnlyCollection<string> Target { get; }

/// <summary>
/// Gets the full name of the target property path.
/// </summary>
public string TargetFullName => string.Join(PropertyAccessSeparatorStr, Target);

/// <summary>
/// Gets or sets the format of the <c>ToString</c> conversion (implementing <see cref="IFormattable" />).
/// </summary>
public string? StringFormat { get; set; }

/// <summary>
/// Gets or sets the name of a format provider field or property to be used for conversions accepting a format provider (implementing <see cref="IFormattable"/>).
/// If <see langword="null"/> the default format provider (annotated with <see cref="FormatProviderAttribute"/> and <see cref="FormatProviderAttribute.Default"/> <see langword="true"/>)
/// or none (if no default format provider is provided) is used.
/// </summary>
public string? FormatProvider { get; set; }

/// <summary>
/// Reference to a unique named mapping method which should be used to map this member.
/// </summary>
public string? Use { get; set; }
}
11 changes: 11 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,14 @@ Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.MapNestedPropertiesAttri
Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.MapNestedPropertiesAttribute(string![]! source) -> void
Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.Source.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
Riok.Mapperly.Abstractions.MapNestedPropertiesAttribute.SourceFullName.get -> string!
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.MapPropertyFromSourceAttribute(string! target) -> void
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.MapPropertyFromSourceAttribute(string![]! target) -> void
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.Target.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.TargetFullName.get -> string!
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.StringFormat.get -> string?
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.StringFormat.set -> void
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.FormatProvider.get -> string?
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.FormatProvider.set -> void
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.Use.get -> string?
Riok.Mapperly.Abstractions.MapPropertyFromSourceAttribute.Use.set -> void
5 changes: 4 additions & 1 deletion src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ private MembersMappingConfiguration BuildMembersConfig(MappingConfigurationRefer
.Select(x => x.Target)
.WhereNotNull()
.ToList();
var memberConfigurations = _dataAccessor.Access<MapPropertyAttribute, MemberMappingConfiguration>(configRef.Method).ToList();
var memberConfigurations = _dataAccessor
.Access<MapPropertyAttribute, MemberMappingConfiguration>(configRef.Method)
.Concat(_dataAccessor.Access<MapPropertyFromSourceAttribute, MemberMappingConfiguration>(configRef.Method))
.ToList();
var nestedMembersConfigurations = _dataAccessor
.Access<MapNestedPropertiesAttribute, NestedMembersMappingConfiguration>(configRef.Method)
.ToList();
Expand Down
6 changes: 6 additions & 0 deletions src/Riok.Mapperly/Configuration/MemberMappingConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ namespace Riok.Mapperly.Configuration;

public record MemberMappingConfiguration(StringMemberPath Source, StringMemberPath Target) : HasSyntaxReference
{
/// <summary>
/// Used to adapt from <see cref="Abstractions.MapPropertyFromSourceAttribute"/>
/// </summary>
public MemberMappingConfiguration(StringMemberPath Target)
: this(Source: StringMemberPath.Empty, Target) { }

public string? StringFormat { get; set; }

public string? FormatProvider { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions src/Riok.Mapperly/Configuration/StringMemberPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ namespace Riok.Mapperly.Configuration;

public record StringMemberPath(IReadOnlyCollection<string> Path)
{
public static readonly StringMemberPath Empty = new(Array.Empty<string>());

public const char MemberAccessSeparator = '.';
private const string MemberAccessSeparatorString = ".";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public class MembersContainerBuilderContext<T>(MappingBuilderContext builderCont

public void AddNullDelegateMemberAssignmentMapping(IMemberAssignmentMapping memberMapping)
{
var nullConditionSourcePath = new MemberPath(memberMapping.SourcePath.PathWithoutTrailingNonNullable().ToList());
var nullConditionSourcePath = MemberPath.Create(
memberMapping.SourceGetter.MemberPath.RootType,
memberMapping.SourceGetter.MemberPath.PathWithoutTrailingNonNullable().ToList()
);
var container = GetOrCreateNullDelegateMappingForPath(nullConditionSourcePath);
AddMemberAssignmentMapping(container, memberMapping);

Expand All @@ -38,7 +41,7 @@ public void AddNullDelegateMemberAssignmentMapping(IMemberAssignmentMapping memb

private void AddMemberAssignmentMapping(IMemberAssignmentMappingContainer container, IMemberAssignmentMapping mapping)
{
SetSourceMemberMapped(mapping.SourcePath);
SetSourceMemberMapped(mapping.SourceGetter.MemberPath);
AddNullMemberInitializers(container, mapping.TargetPath);
container.AddMemberMapping(mapping);
}
Expand All @@ -47,7 +50,7 @@ private void AddNullMemberInitializers(IMemberAssignmentMappingContainer contain
{
foreach (var nullableTrailPath in path.ObjectPathNullableSubPaths())
{
var nullablePath = new MemberPath(nullableTrailPath);
var nullablePath = new NonEmptyMemberPath(path.RootType, nullableTrailPath);
var type = nullablePath.Member.Type;

if (!nullablePath.Member.CanSet)
Expand Down Expand Up @@ -87,7 +90,12 @@ private MemberNullDelegateAssignmentMapping GetOrCreateNullDelegateMappingForPat
var needsNullSafeAccess = false;
foreach (var nullablePath in nullConditionSourcePath.ObjectPathNullableSubPaths().Reverse())
{
if (_nullDelegateMappings.TryGetValue(new MemberPath(nullablePath), out var parentMappingHolder))
if (
_nullDelegateMappings.TryGetValue(
new NonEmptyMemberPath(nullConditionSourcePath.RootType, nullablePath),
out var parentMappingHolder
)
)
{
parentMapping = parentMappingHolder;
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ protected MembersMappingBuilderContext(MappingBuilderContext builderContext, T m

// source and target properties may have been ignored and mapped explicitly
_mappedAndIgnoredSourceMemberNames = MemberConfigsByRootTargetName
.Values.SelectMany(v => v.Select(s => s.Source.Path.First()))
.Values.SelectMany(v => v.Where(s => s.Source.Path.Count > 0).Select(s => s.Source.Path.First()))
.ToHashSet();
_mappedAndIgnoredSourceMemberNames.IntersectWith(IgnoredSourceMemberNames);

Expand Down Expand Up @@ -113,7 +113,16 @@ public void AddDiagnostics()
protected void SetSourceMemberMapped(MemberPath sourcePath)
{
_hasMemberMapping = true;
_unmappedSourceMemberNames.Remove(sourcePath.Path.First().Name);

if (sourcePath.Path.FirstOrDefault() is { } sourceMember)
{
_unmappedSourceMemberNames.Remove(sourceMember.Name);
}
else
// Assume all source members are used when the source object is used itself.
{
_unmappedSourceMemberNames.Clear();
}
}

public bool TryFindNestedSourceMembersPath(
Expand Down Expand Up @@ -151,7 +160,10 @@ out var nestedSourceMemberPath
)
)
{
sourceMemberPath = new MemberPath(nestedMemberPath.Path.Concat(nestedSourceMemberPath.Path).ToList());
sourceMemberPath = new NonEmptyMemberPath(
Mapping.SourceType,
nestedMemberPath.Path.Concat(nestedSourceMemberPath.Path).ToList()
);
_unusedNestedMemberPaths.Remove(nestedMemberPath.FullName);
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ public NewInstanceBuilderContext(MappingBuilderContext builderContext, T mapping

public void AddInitMemberMapping(MemberAssignmentMapping mapping)
{
SetSourceMemberMapped(mapping.SourcePath);
SetSourceMemberMapped(mapping.SourceGetter.MemberPath);
Mapping.AddInitMemberMapping(mapping);
}

public void AddConstructorParameterMapping(ConstructorParameterMapping mapping)
{
var paramName = RootTargetNameCasingMapping.GetValueOrDefault(mapping.Parameter.Name, defaultValue: mapping.Parameter.Name);
MemberConfigsByRootTargetName.Remove(paramName);
SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
SetSourceMemberMapped(mapping.DelegateMapping.SourceGetter.MemberPath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ public NewInstanceContainerBuilderContext(MappingBuilderContext builderContext,

public void AddInitMemberMapping(MemberAssignmentMapping mapping)
{
SetSourceMemberMapped(mapping.SourcePath);
SetSourceMemberMapped(mapping.SourceGetter.MemberPath);
Mapping.AddInitMemberMapping(mapping);
}

public void AddConstructorParameterMapping(ConstructorParameterMapping mapping)
{
var paramName = RootTargetNameCasingMapping.GetValueOrDefault(mapping.Parameter.Name, defaultValue: mapping.Parameter.Name);
MemberConfigsByRootTargetName.Remove(paramName);
SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
SetSourceMemberMapped(mapping.DelegateMapping.SourceGetter.MemberPath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class NewValueTupleConstructorBuilderContext<T>(MappingBuilderContext bui
public void AddTupleConstructorParameterMapping(ValueTupleConstructorParameterMapping mapping)
{
MemberConfigsByRootTargetName.Remove(mapping.Parameter.Name);
SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
SetSourceMemberMapped(mapping.DelegateMapping.SourceGetter.MemberPath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public void AddTupleConstructorParameterMapping(ValueTupleConstructorParameterMa
}
}

SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
SetSourceMemberMapped(mapping.DelegateMapping.SourceGetter.MemberPath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ private static void BuildInitMemberMapping(
MemberMappingConfiguration? memberConfig = null
)
{
var targetPath = new MemberPath(new[] { targetMember });
var targetPath = new NonEmptyMemberPath(ctx.Mapping.TargetType, new[] { targetMember });
if (!ObjectMemberMappingBodyBuilder.ValidateMappingSpecification(ctx, sourcePath, targetPath, true))
return;

Expand All @@ -141,12 +141,8 @@ private static void BuildInitMemberMapping(
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.CouldNotMapMember,
ctx.Mapping.SourceType,
sourcePath.FullName,
sourcePath.Member.Type,
ctx.Mapping.TargetType,
targetPath.FullName,
targetPath.Member.Type
sourcePath.ToDisplayString(),
targetPath.ToDisplayString()
);
return;
}
Expand All @@ -155,10 +151,8 @@ private static void BuildInitMemberMapping(
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ReferenceLoopInInitOnlyMapping,
ctx.Mapping.SourceType,
sourcePath.FullName,
ctx.Mapping.TargetType,
targetPath.FullName
sourcePath.ToDisplayString(includeMemberType: false),
targetPath.ToDisplayString(includeMemberType: false)
);
return;
}
Expand Down Expand Up @@ -266,8 +260,7 @@ private static bool TryBuildConstructorMapping(
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ReferenceLoopInCtorMapping,
ctx.Mapping.SourceType,
sourcePath.FullName,
sourcePath.ToDisplayString(includeMemberType: false),
ctx.Mapping.TargetType,
parameter.Name
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,8 @@ out HashSet<string> mappedTargetMemberNames
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.CouldNotMapMember,
ctx.Mapping.SourceType,
sourcePath.FullName,
sourcePath.Member.Type,
ctx.Mapping.TargetType,
targetMember.Name,
targetMember.Type
sourcePath.ToDisplayString(),
FormatTargetMemberForDiagnostic(ctx.Mapping.TargetType, targetMember)
);
return false;
}
Expand All @@ -104,8 +100,7 @@ out HashSet<string> mappedTargetMemberNames
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ReferenceLoopInCtorMapping,
ctx.Mapping.SourceType,
sourcePath.FullName,
sourcePath.ToDisplayString(includeMemberType: false),
ctx.Mapping.TargetType,
targetMember.Name
);
Expand All @@ -121,6 +116,14 @@ out HashSet<string> mappedTargetMemberNames
return true;
}

/// <summary>
/// Formats the target member in the same way that <see cref="MemberPath.ToDisplayString"/> does.
/// </summary>
private static string FormatTargetMemberForDiagnostic(ITypeSymbol targetType, IFieldSymbol targetMember)
{
return $"{targetType.ToDisplayString()}.{targetMember.Name} of type {targetMember.Type.ToDisplayString()}";
}

private static bool TryFindConstructorParameterSourcePath(
INewValueTupleBuilderContext<INewValueTupleMapping> ctx,
IFieldSymbol field,
Expand Down Expand Up @@ -188,7 +191,7 @@ out MemberPath? sourcePath
if (mappableField == default)
return false;

sourcePath = new MemberPath(new[] { new FieldMember(mappableField, ctx.BuilderContext.SymbolAccessor) });
sourcePath = new NonEmptyMemberPath(namedType, new[] { new FieldMember(mappableField, ctx.BuilderContext.SymbolAccessor) });
return true;
}
}
Loading

0 comments on commit d790055

Please sign in to comment.