Skip to content

Feat: 2253 flatten collections#2279

Open
saijaku0 wants to merge 2 commits into
riok:mainfrom
saijaku0:feat/2253-flatten-collections
Open

Feat: 2253 flatten collections#2279
saijaku0 wants to merge 2 commits into
riok:mainfrom
saijaku0:feat/2253-flatten-collections

Conversation

@saijaku0
Copy link
Copy Markdown

Closes #2253

Summary

Adds support for collection flattening in [MapProperty] paths. When a path segment refers to a collection-typed member (e.g. an ICollection<UserRole> from an EF Core m:n join table), Mapperly now resolves the remainder of the path against the collection's element type and generates the appropriate Select/ToList calls.

This removes the need for a hand-written intermediate user-mapping method when projecting through a join table a common pain point with EF Core m:n relations.

Example

public class User { public ICollection<UserRole> UserRoles { get; set; } = []; }
public class UserRole { public Role Role { get; set; } = null!; }
public class Role { public string Name { get; set; } = ""; }

public class UserDto { public List<RoleDto> Roles { get; set; } = []; }
public class RoleDto { public string Name { get; set; } = ""; }

[Mapper]
public static partial class UserDtoMapper
{
    public static partial RoleDto MapToRoleDto(Role src);

    [MapProperty("UserRoles.Role", "Roles")]
    public static partial UserDto MapToUserDto(User src);
}

Before this PR, the path "UserRoles.Role" could not be resolved - the user had to write an extra private static RoleDto Map(UserRole) => MapToRoleDto(src.Role). After this PR, the path is resolved automatically.

Implementation approach

A new abstract CollectionElementMember (in Symbols/Members/) acts as a pseudo-member in NonEmptyMemberPath. It is not a real type member it signals to MemberPathGetter that traversal crosses a collection boundary and that the element type should be used for subsequent path resolution.

The path "UserRoles.Role" is parsed into:

UserRoles  (ICollection<UserRole>)
  → CollectionElementMember  (pseudo, element type = UserRole)
  → Role  (Role property on UserRole)

MemberPathGetter.BuildAccess then emits the inner Select(x.UserRoles, x1 => x1.Role) for the collection-crossing segment, and the existing LinqEnumerableMapping infrastructure handles the outer projection to RoleDto. This means existing collection mapping, null-handling and diagnostics are reused without duplication.

Trade-off: nested Select vs flat Select

The current implementation generates nested Select calls for both regular and queryable-projection mappings:

// Generated today
Roles = ToList(
    Select(
        Select(source.UserRoles, x1 => x1.Role),    // inner: extract Role
        x => MapToRoleDto(x)                          // outer: project Role -> RoleDto
    )
);

The original issue example shows a flat form, where the navigation through .Role is inlined into the projection lambda:

// Issue's expected form
Roles = ToList(
    Select(source.UserRoles, x1 => new RoleDto { Name = x1.Role.Name })
);

For regular method mappings, the nested form compiles to identical IL and runs equivalently - the extra Select is fused by the JIT.

For IQueryable<T> projections (EF Core), the flat form translates to cleaner SQL, while the nested form may produce extra subqueries depending on the provider. EF Core 8+ handles both, but flat is the more conservative choice.

I chose the nested form for this PR because it cleanly reuses LinqEnumerableMapping without modifying its signature, keeping the change small and contained to the new CollectionElementMember machinery. A flat-form implementation requires deeper integration (see below).

Path to flat Select (follow-up)

If flat Select is preferred, the change set looks like this:

File Change
LinqEnumerableMapping.cs Add optional MemberPathGetter? elementSourceTransform parameter
LinqEnumerableMapping.Build() Apply elementSourceTransform to lambdaCtx.Source before calling elementMapping.Build
NonEmptyMemberPath.cs Expose CollectionPath and ElementPath segments (split by CollectionElementMember)
MemberPathGetter.BuildAccess() For collection-through paths, return only the collection access (e.g. x.UserRoles) without the inner Select
MappedMemberSourceValue creation site Pass the element getter through to LinqEnumerableMapping

Happy to do this as a follow-up commit in this PR if you'd prefer flat from the start, or as a separate PR after this one merges. I'd appreciate guidance on which path you'd prefer.

Tests

Added ObjectPropertyCollectionThroughTest covering:

  • Collection-through with ICollection<T>, List<T>, IEnumerable<T>, T[]
  • Path expressed via nameof interpolation
  • Nullable source collection (correct diagnostics + null-guard generation)
  • Multiple [MapProperty] attributes on the same mapper
  • Non-existent member at the end of the path (correct diagnostic)
  • Path through a non-collection (existing scalar flattening unaffected)
  • Queryable projection (verified via Verify snapshot)
  • Deep path with two collection levels (Dept.Teams.Members)

All existing tests still pass.

Open questions for reviewers

  1. Nested vs flat Select - see trade-off above. Should I rework to flat as part of this PR, or follow up?
  2. Naming: CollectionElementMember - happy to rename if there's a convention I missed.
  3. Diagnostics: when the path segment after a collection doesn't exist on the element type, the current diagnostic message reads "Specified member UserRoles.NonExistent on source type User was not found". Should the message explicitly mention the collection traversal (e.g. "...on element type UserRole of collection UserRoles..."), or is the current form fine?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support flattening collections

1 participant