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
41 changes: 36 additions & 5 deletions src/Riok.Mapperly/Descriptors/SymbolAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -567,16 +567,47 @@ private bool TryFindPath(ITypeSymbol type, StringMemberPath path, bool ignoreCas
{
// get T if type is Nullable<T>, prevents Value being treated as a member
var actualType = type.NonNullableValueType() ?? type;
if (GetMappableMember(actualType, name, ignoreCase) is not { } member)
return false;
if (GetMappableMember(actualType, name, ignoreCase) is { } member)
{
type = member.Type;
foundPath.Add(member);
continue;
}

type = member.Type;
foundPath.Add(member);
if (
TryGetCollectionElementType(actualType, out var elementType)
&& GetMappableMember(elementType, name, ignoreCase) is { } elementMember
)
{
foundPath.Add(new CollectionElementMember(actualType, elementType));
type = elementMember.Type;
foundPath.Add(elementMember);
continue;
}
return false;
}

return true;
}

private bool TryGetCollectionElementType(ITypeSymbol type, [NotNullWhen(true)] out ITypeSymbol? elementType)
{
if (
type is INamedTypeSymbol { IsGenericType: true } named
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, EnumerableTypeSymbol)
)
{
elementType = named.TypeArguments[0];
return true;
}
if (type.ImplementsGeneric(EnumerableTypeSymbol, out var impl))
{
elementType = impl.TypeArguments[0];
return true;
}
elementType = null;
return false;
}

public IMappableMember? GetMappableMember(ITypeSymbol symbol, string name, bool ignoreCase = false)
{
var membersBySymbol = ignoreCase ? _allAccessibleMembersCaseInsensitive : _allAccessibleMembersCaseSensitive;
Expand Down
45 changes: 45 additions & 0 deletions src/Riok.Mapperly/Symbols/Members/CollectionElementMember.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors;
using Riok.Mapperly.Descriptors.UnsafeAccess;

namespace Riok.Mapperly.Symbols.Members;

/// <summary>
/// Abstract pseudo-member representing a collection operation in a member path.
/// Subclasses are not real type members — they signal to <see cref="MemberPathGetter"/>
/// that it should emit a LINQ Select() to access the collection element type.
/// </summary>
internal sealed class CollectionElementMember(ITypeSymbol type, ITypeSymbol? typeSymbol) : IMappableMember, IMemberGetter
{
public string Name => "[]";
public ITypeSymbol Type { get; } = typeSymbol ?? type;
public ITypeSymbol CollectionType { get; } = type;
public INamedTypeSymbol? ContainingType => null;
public bool IsReadNullable => Type.NullableAnnotation == NullableAnnotation.Annotated;
public bool IsWriteNullable => false;
public bool CanGet => true;
public bool CanGetDirectly => true;
public bool CanSet => false;
public bool CanSetDirectly => false;
public bool IsInitOnly => false;
public bool IsRequired => false;
public bool IsObsolete => false;

public bool IsIgnored(MappingBuilderContext ctx) => false;

public IMemberGetter BuildGetter(UnsafeAccessorContext ctx) => this;

public IMemberSetter BuildSetter(UnsafeAccessorContext ctx) =>
throw new InvalidOperationException($"{GetType().Name} cannot be used as a mapping setter.");

/// <summary>
/// Not called directly <see cref="MemberPathGetter"/> handles collection members specially,
/// emitting a LINQ Select() rather than direct member access.
/// </summary>
public ExpressionSyntax BuildAccess(
ExpressionSyntax? baseAccess,
INamedTypeSymbol? containingType = null,
bool nullConditional = false
) => throw new InvalidOperationException($"{GetType().Name} must be handled by {nameof(MemberPathGetter)}, not called directly.");
}
41 changes: 41 additions & 0 deletions src/Riok.Mapperly/Symbols/Members/MemberPathGetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors;
using Riok.Mapperly.Helpers;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper;
using MemberGetterPair = (Riok.Mapperly.Symbols.Members.IMappableMember Member, Riok.Mapperly.Symbols.Members.IMemberGetter Getter);

Expand All @@ -14,6 +15,7 @@ namespace Riok.Mapperly.Symbols.Members;
[DebuggerDisplay("{MemberPath}")]
public class MemberPathGetter
{
private const string SelectMethodName = "global::System.Linq.Enumerable.Select";
private const string NullableValueProperty = nameof(Nullable<>.Value);

public MemberPath MemberPath { get; }
Expand Down Expand Up @@ -41,9 +43,48 @@ public static MemberPathGetter Build(SimpleMappingBuilderContext ctx, MemberPath
)
{
var path = skipTrailingNonNullable ? ReadPathWithoutTrailingNonNullable() : _path;
if (_path.Any(p => p.Member is CollectionElementMember))
return BuildSelectAccess(baseAccess, path.ToList(), depth: 1);

return BuildAccess(baseAccess, path, addValuePropertyOnNullable, nullConditional);
}

/// <summary>
/// Builds a select access for the path, if it contains a collection element member. For example, for a path like
/// "Orders.Select(x => x.OrderLines).Select(x => x.Price)", it will build the access for the "OrderLines" part and
/// then wrap it in a select for the "Price" part.
/// </summary>
/// <param name="baseAccess">The base access expression.</param>
/// <param name="path">The path of members to access.</param>
/// <param name="depth">The depth of the current select operation.</param>
/// <returns>The built select access expression.</returns>
private static ExpressionSyntax? BuildSelectAccess(ExpressionSyntax? baseAccess, IReadOnlyList<MemberGetterPair> path, int depth)
{
var splitIdx = -1;
for (var i = 0; i < path.Count; i++)
{
if (path[i].Member is CollectionElementMember)
{
splitIdx = i;
break;
}
}

if (splitIdx < 0)
return path.Aggregate(baseAccess, (a, b) => b.Getter.BuildAccess(a, b.Member.ContainingType));

var collectionExpr = path.Take(splitIdx).Aggregate(baseAccess, (a, b) => b.Getter.BuildAccess(a, b.Member.ContainingType));

var lambdaParamName = $"x{depth}";
var lambdaParamExpr = IdentifierName(lambdaParamName);

var remainder = path.Skip(splitIdx + 1).ToList();
var lambdaBody = BuildSelectAccess(lambdaParamExpr, remainder, depth + 1);

var lambda = Lambda(lambdaParamName, lambdaBody!);
return InvocationWithoutIndention(SelectMethodName, collectionExpr!, lambda);
}

[return: NotNullIfNotNull(nameof(baseAccess))]
private ExpressionSyntax? BuildAccess(
ExpressionSyntax? baseAccess,
Expand Down
49 changes: 47 additions & 2 deletions src/Riok.Mapperly/Symbols/Members/NonEmptyMemberPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,19 @@ public NonEmptyMemberPath(ITypeSymbol rootType, IReadOnlyList<IMappableMember> p
/// <summary>
/// Gets the type of the <see cref="Member"/> in the context of read. If any part of the path is nullable, this type will be nullable too.
/// </summary>
public override ITypeSymbol MemberReadType =>
_memberReadType ??= IsAnyReadNullable() ? Member.Type.WithNullableAnnotation(NullableAnnotation.Annotated) : Member.Type;
public override ITypeSymbol MemberReadType
{
get
{
if (_memberReadType != null)
return _memberReadType;
var baseType = IsAnyReadNullable() ? Member.Type.WithNullableAnnotation(NullableAnnotation.Annotated) : Member.Type;

if (Path.Any(m => m is CollectionElementMember))
return _memberReadType = WrapInIEnumerable(baseType);
return _memberReadType = baseType;
}
}

/// <summary>
/// Gets the type of the <see cref="Member"/> in the context of write. If last part of the path is nullable, this type will be nullable too.
Expand All @@ -44,6 +55,40 @@ public NonEmptyMemberPath(ITypeSymbol rootType, IReadOnlyList<IMappableMember> p

public override bool IsWriteNullable() => Path[^1].IsWriteNullable;

/// <summary>
/// If the path contains a <see cref="CollectionElementMember"/>, the member type is wrapped in an <see cref="IEnumerable{T}"/>.
/// This is required to support mapping of collection elements.
/// </summary>
/// <param name="elementType"></param>
/// <returns></returns>
private ITypeSymbol WrapInIEnumerable(ITypeSymbol elementType)
{
var collectionMember = (CollectionElementMember)Path.First(m => m is CollectionElementMember);
//var iEnumerable = collectionMember.CollectionType.AllInterfaces.FirstOrDefault(i =>
// i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T);
var collectionType = collectionMember.CollectionType;

INamedTypeSymbol? iEnumerable;
if (
collectionType is INamedTypeSymbol { IsGenericType: true } namedCollection
&& namedCollection.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T
)
{
iEnumerable = namedCollection;
}
else
{
iEnumerable = collectionMember.CollectionType.AllInterfaces.FirstOrDefault(i =>
i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T
);
}

if (iEnumerable == null)
return elementType;

return iEnumerable.OriginalDefinition.Construct(elementType);
}

public override string ToDisplayString(bool includeRootType = true, bool includeMemberType = true)
{
var ofType = includeMemberType ? $" of type {Member.Type.ToDisplayString()}" : null;
Expand Down
Loading