Skip to content

Commit

Permalink
feat: add tuple mapping support
Browse files Browse the repository at this point in the history
  • Loading branch information
TimothyMakkison committed Jul 17, 2023
1 parent 984c695 commit ea25d06
Show file tree
Hide file tree
Showing 51 changed files with 1,270 additions and 4 deletions.
1 change: 1 addition & 0 deletions docs/docs/configuration/conversions.md
Expand Up @@ -14,6 +14,7 @@ Mapperly implements several types of automatic conversions (in order of priority
| Dictionary | Maps a source dictionary to an enumerable target | Source type is an `IDictionary<,>` or an `IReadOnlyDictionary<,>` |
| Enumerable | Maps an enumerable source to an enumerable target | Source type is an `IEnumerable<>` |
| Span | Maps a `Span<>`, `ReadOnlySpan<>` to or from `Span<>`, `ReadOnlySpan<>` or enumerable | Source or target type is a `Span<>`, `ReadOnlySpan<>` |
| Tuple | Create a new instance of a `ValueTuple` or tuple expression i.e. `(10, 12)` | Target type is a `ValueTuple<>` or tuple expression |
| Memory | Maps a `Memory<>`, `ReadOnlyMemory<>` to or from `Memory<>`, `ReadOnlyMemory<>`, `Span<>`, `ReadOnlySpan<>` or enumerable | Source or target type is a `Memory<>` or `ReadOnlyMemory<>` |
| Implicit cast | Implicit cast operator | An implicit cast operator is defined to cast from the source type to the target type |
| Parse method | Uses a static `Parse` method on the target type | Source type is a `string` and target has a static method with the following signature: `TTarget Parse(string)`. |
Expand Down
7 changes: 7 additions & 0 deletions src/Riok.Mapperly.Abstractions/MappingConversionType.cs
Expand Up @@ -107,6 +107,13 @@ public enum MappingConversionType
/// </summary>
Memory = 1 << 14,

/// <summary>
/// If the target is a <see cref="ValueTuple{T, U}"/> or tuple expression (A: 10, B: 12).
/// Supports positional and named mapping.
/// Only uses <see cref="ValueTuple{T, U}"/> in <see cref="IQueryable{T}"/>.
/// </summary>
Tuple = 1 << 15,

/// <summary>
/// Enables all supported conversions.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Expand Up @@ -78,6 +78,7 @@ Riok.Mapperly.Abstractions.MappingConversionType.ParseMethod = 8 -> Riok.Mapperl
Riok.Mapperly.Abstractions.MappingConversionType.Span = 8192 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.StringToEnum = 32 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.ToStringMethod = 16 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.Tuple = 32768 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.Queryable = 1024 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MapperAttribute.UseReferenceHandling.get -> bool
Riok.Mapperly.Abstractions.MapperAttribute.UseReferenceHandling.set -> void
Expand Down
@@ -0,0 +1,15 @@
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;

/// <summary>
/// A <see cref="IMembersBuilderContext{T}"/> for mappings which create the target object
/// via a tuple expression (eg. (A: source.A, B: MapToB(source.B))).
/// </summary>
/// <typeparam name="T">The mapping type.</typeparam>
public interface INewValueTupleBuilderContext<out T> : IMembersBuilderContext<T>
where T : IMapping
{
void AddTupleConstructorParameterMapping(ValueTupleConstructorParameterMapping mapping);
}
@@ -0,0 +1,22 @@
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;

/// <summary>
/// An implementation of <see cref="INewValueTupleBuilderContext{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the mapping.</typeparam>
public class NewValueTupleConstructorBuilderContext<T> : MembersMappingBuilderContext<T>, INewValueTupleBuilderContext<T>
where T : INewValueTupleMapping
{
public NewValueTupleConstructorBuilderContext(MappingBuilderContext builderContext, T mapping)
: base(builderContext, mapping) { }

public void AddTupleConstructorParameterMapping(ValueTupleConstructorParameterMapping mapping)
{
MemberConfigsByRootTargetName.Remove(mapping.Parameter.Name);
SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
@@ -0,0 +1,26 @@
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;

/// <summary>
/// An implementation of <see cref="INewValueTupleBuilderContext{T}"/>.
/// </summary>
/// <typeparam name="T">The type of the mapping.</typeparam>
public class NewValueTupleExpressionBuilderContext<T> : MembersContainerBuilderContext<T>, INewValueTupleBuilderContext<T>
where T : INewValueTupleMapping, IMemberAssignmentTypeMapping
{
public NewValueTupleExpressionBuilderContext(MappingBuilderContext builderContext, T mapping)
: base(builderContext, mapping) { }

public void AddTupleConstructorParameterMapping(ValueTupleConstructorParameterMapping mapping)
{
if (MemberConfigsByRootTargetName.TryGetValue(mapping.Parameter.Name, out var value))
{
value.RemoveAll(x => x.Target.Path.Count == 1);
}

SetSourceMemberMapped(mapping.DelegateMapping.SourcePath);
Mapping.AddConstructorParameterMapping(mapping);
}
}
Expand Up @@ -28,6 +28,12 @@ public void BuildMappingBodies()
case NewInstanceObjectMemberMapping mapping:
NewInstanceObjectMemberMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
case NewValueTupleExpressionMapping mapping:
NewValueTupleMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
case NewValueTupleConstructorMapping mapping:
NewValueTupleMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
case IMemberAssignmentTypeMapping mapping:
ObjectMemberMappingBodyBuilder.BuildMappingBody(ctx, mapping);
break;
Expand Down
@@ -0,0 +1,225 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings;
using Riok.Mapperly.Diagnostics;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders;

public static class NewValueTupleMappingBodyBuilder
{
public static void BuildMappingBody(MappingBuilderContext ctx, NewValueTupleConstructorMapping expressionMapping)
{
var mappingCtx = new NewValueTupleConstructorBuilderContext<NewValueTupleConstructorMapping>(ctx, expressionMapping);
BuildTupleConstructorMapping(mappingCtx);
mappingCtx.AddDiagnostics();
}

public static void BuildMappingBody(MappingBuilderContext ctx, NewValueTupleExpressionMapping expressionMapping)
{
var mappingCtx = new NewValueTupleExpressionBuilderContext<NewValueTupleExpressionMapping>(ctx, expressionMapping);
BuildTupleConstructorMapping(mappingCtx);
ObjectMemberMappingBodyBuilder.BuildMappingBody(mappingCtx);
mappingCtx.AddDiagnostics();
}

private static void BuildTupleConstructorMapping(INewValueTupleBuilderContext<INewValueTupleMapping> ctx)
{
if (ctx.Mapping.TargetType is not INamedTypeSymbol namedTargetType)
{
ctx.BuilderContext.ReportDiagnostic(DiagnosticDescriptors.NoConstructorFound, ctx.BuilderContext.Target);
return;

Check warning on line 35 in src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs#L33-L35

Added lines #L33 - L35 were not covered by tests
}

if (!TryBuildTupleConstructorMapping(ctx, namedTargetType, out var constructorParameterMappings, out var mappedTargetMemberNames))
{
ctx.BuilderContext.ReportDiagnostic(DiagnosticDescriptors.NoConstructorFound, ctx.BuilderContext.Target);
return;
}

var removableMappedTargetMemberNames = mappedTargetMemberNames.Where(x =>
{
ctx.MemberConfigsByRootTargetName.TryGetValue(x, out var value);
return !value?.Any() ?? true;
});

ctx.TargetMembers.RemoveRange(removableMappedTargetMemberNames);
foreach (var constructorParameterMapping in constructorParameterMappings)
{
ctx.AddTupleConstructorParameterMapping(constructorParameterMapping);
}
}

private static bool TryBuildTupleConstructorMapping(
INewValueTupleBuilderContext<INewValueTupleMapping> ctx,
INamedTypeSymbol namedTargetType,
out HashSet<ValueTupleConstructorParameterMapping> constructorParameterMappings,
out HashSet<string> mappedTargetMemberNames
)
{
mappedTargetMemberNames = new HashSet<string>();
constructorParameterMappings = new HashSet<ValueTupleConstructorParameterMapping>();

foreach (var targetMember in namedTargetType.TupleElements)
{
if (!ctx.TargetMembers.ContainsKey(targetMember.Name))
{
return false;
}
if (!TryFindConstructorParameterSourcePath(ctx, targetMember, out var sourcePath))
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.SourceMemberNotFound,
targetMember.Name,
ctx.Mapping.TargetType,
ctx.Mapping.SourceType
);

return false;
}

// nullability is handled inside the member expressionMapping
var paramType = targetMember.Type.WithNullableAnnotation(targetMember.NullableAnnotation);
var delegateMapping =
ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType)
?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType.NonNullable());

if (delegateMapping == null)
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.CouldNotMapMember,
ctx.Mapping.SourceType,
sourcePath.FullName,
sourcePath.Member.Type,
ctx.Mapping.TargetType,
targetMember.Name,
targetMember.Type
);
return false;

Check warning on line 102 in src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs#L92-L102

Added lines #L92 - L102 were not covered by tests
}

if (delegateMapping.Equals(ctx.Mapping))
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ReferenceLoopInCtorMapping,
ctx.Mapping.SourceType,
sourcePath.FullName,
ctx.Mapping.TargetType,
targetMember.Name
);
return false;

Check warning on line 114 in src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs#L106-L114

Added lines #L106 - L114 were not covered by tests
}

var memberMapping = new NullMemberMapping(
delegateMapping,
sourcePath,
paramType,
ctx.BuilderContext.GetNullFallbackValue(paramType),
!ctx.BuilderContext.IsExpression
);

var ctorMapping = new ValueTupleConstructorParameterMapping(targetMember, memberMapping);
constructorParameterMappings.Add(ctorMapping);
mappedTargetMemberNames.Add(targetMember.Name);
}

return true;
}

private static bool TryFindConstructorParameterSourcePath(
INewValueTupleBuilderContext<INewValueTupleMapping> ctx,
IFieldSymbol field,
[NotNullWhen(true)] out MemberPath? sourcePath
)
{
sourcePath = null;

if (!ctx.MemberConfigsByRootTargetName.TryGetValue(field.Name, out var memberConfigs))
{
return TryBuildConstructorParameterSourcePath(ctx, field, out sourcePath);
}

if (memberConfigs.Count > 1)
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.MultipleConfigurationsForConstructorParameter,
field.Type,
field.Name
);
}

var memberConfig = memberConfigs.First();
if (memberConfig.Target.Path.Count > 1)
{
return TryBuildConstructorParameterSourcePath(ctx, field, out sourcePath);
}

if (!ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(ctx.Mapping.SourceType, memberConfig.Source.Path, out sourcePath))
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.SourceMemberNotFound,
memberConfig.Source,
ctx.Mapping.TargetType,
ctx.Mapping.SourceType
);
return false;

Check warning on line 169 in src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs#L162-L169

Added lines #L162 - L169 were not covered by tests
}

return true;
}

private static bool TryBuildConstructorParameterSourcePath(
INewValueTupleBuilderContext<INewValueTupleMapping> ctx,
IFieldSymbol field,
out MemberPath? sourcePath
)
{
var memberNameComparer =
ctx.BuilderContext.MapperConfiguration.PropertyNameMappingStrategy == PropertyNameMappingStrategy.CaseSensitive
? StringComparer.Ordinal
: StringComparer.OrdinalIgnoreCase;

if (
ctx.BuilderContext.SymbolAccessor.TryFindMemberPath(
ctx.Mapping.SourceType,
MemberPathCandidateBuilder.BuildMemberPathCandidates(field.Name),
ctx.IgnoredSourceMemberNames,
memberNameComparer,
out sourcePath
)
)
return true;

// if standard matching fails, use the underlying field
// if source is a tuple compare the underlying field ie, Item1, Item2
// if source is not a tuple compare top level members
if (ctx.Mapping.SourceType.IsTupleType)
{
if (ctx.Mapping.SourceType is not INamedTypeSymbol namedType)
return false;

Check warning on line 203 in src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs

View check run for this annotation

Codecov / codecov/patch

src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewValueTupleMappingBodyBuilder.cs#L203

Added line #L203 was not covered by tests

var mappableMember = namedType.TupleElements
.Where(
x =>
x.CorrespondingTupleField != default
&& !ctx.IgnoredSourceMemberNames.Contains(x.Name)
&& string.Equals(field.CorrespondingTupleField!.Name, x.CorrespondingTupleField!.Name)
)
.Select(MappableMember.Create)
.WhereNotNull()
.FirstOrDefault();

if (mappableMember != default)
{
sourcePath = new MemberPath(new[] { mappableMember });
return true;
}
}

return false;
}
}
Expand Up @@ -16,6 +16,12 @@ public static class ExplicitCastMappingBuilder
if (ctx.MapperConfiguration.UseDeepCloning && !ctx.Source.IsImmutable() && !ctx.Target.IsImmutable())
return null;

// ClassifyConversion does not check if tuple field member names are the same
// if tuple check isn't done then (A: int, B: int) -> (B: int, A: int) would be mapped
// return ((int, int))source; instead of return (B: source.A, A: source.B);
if (ctx.Target.IsTupleType)
return null;

if (SymbolEqualityComparer.Default.Equals(ctx.Source, ctx.Compilation.ObjectType))
return null;

Expand Down
Expand Up @@ -15,6 +15,12 @@ public static class ImplicitCastMappingBuilder
if (ctx.MapperConfiguration.UseDeepCloning && !ctx.Source.IsImmutable() && !ctx.Target.IsImmutable())
return null;

// ClassifyConversion does not check if tuple field member names are the same
// if tuple check isn't done then (A: int, B: int) -> (B: int, A: int) would be mapped
// return source; instead of return (B: source.A, A: source.B);
if (ctx.Target.IsTupleType)
return null;

var conversion = ctx.Compilation.ClassifyConversion(ctx.Source, ctx.Target);
return conversion.IsImplicit ? new CastMapping(ctx.Source, ctx.Target) : null;
}
Expand Down
@@ -1,4 +1,5 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.ExistingTarget;
using Riok.Mapperly.Helpers;
Expand Down Expand Up @@ -26,6 +27,21 @@ public static class NewInstanceObjectPropertyMappingBuilder
if (ctx.Source.IsEnum() || ctx.Target.IsEnum())
return null;

if (ctx.Target.IsTupleType)
{
if (!ctx.IsConversionEnabled(MappingConversionType.Tuple))
return null;

// inline expressions don't support tuple expressions so ValueTuple is used instead
if (ctx.IsExpression)
{
return new NewValueTupleConstructorMapping(ctx.Source, ctx.Target);
}

var expectedArgumentCount = (ctx.Target as INamedTypeSymbol)!.TupleElements.Length;
return new NewValueTupleExpressionMapping(ctx.Source, ctx.Target, expectedArgumentCount);
}

// inline expressions don't support method property mappings
// and can only map to properties via object initializers.
return ctx.IsExpression
Expand Down

0 comments on commit ea25d06

Please sign in to comment.