'How to combine ProjectTo with Map using Automapper?

To make it short here are database entities:

public class Client
{
    [Key]
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    public ICollection<ClientAddress> Addresses { get; set; }
}

public abstract class ClientAddress : ClientSubEntityBase
{
    public int ClientId { get; set; }

    [Required]
    public virtual AddressType AddressType { get; protected set; }

    [Required]
    public string Address { get; set; }
}

public enum AddressType
{
    Fact = 1,
    Registered = 2,
}

public class ClientAddressFact : ClientAddress
{
    public override AddressType AddressType { get; protected set; } = AddressType.Fact;
    public string SpecificValue_Fact { get; set; }
}
public class ClientAddressRegistered : ClientAddress
{
    public override AddressType AddressType { get; protected set; } = AddressType.Registered;
    public string SpecificValue_Registered { get; set; }
}

These are mapped by EF Core 6 to TPH correctly. When reading values back we get ClientAddressFact and ClientAddressRegistered correspondingly to AddressType inside Client.Addresses.

Now I need to convert these to my DTOs:

public record Client
{
    public string Name { get; init; }
    public IEnumerable<ClientAddress> Addresses { get; init; }
}

public abstract record ClientAddress
{
    public ClientAddressType AddressType { get; init; }
    public string Address { get; init; }
}

public enum ClientAddressType
{
    Fact,
    Registered,
}

public record ClientAddressFact : ClientAddress
{
    public string SpecificValue_Fact { get; init; }
}
public record ClientAddressRegistered : ClientAddress
{
    public string SpecificValue_Registered { get; init; }
}

Obviously using ProjectTo won't work since there is no way to construct a correct SELECT statement out of LINQ and create corresponding entity types. So the idea is to first ProjectTo address list to something like this:

public record ClientAddressCommon : ClientAddress
{
    public string SpecificValue_Fact { get; init; }
    public string SpecificValue_Registered { get; init; }
}

And then Map these to correct entity types so in the end I could get my correct Client DTO with correct ClientAddressFact and ClientAddressRegistered filled inside Addresses.

But the question is how do I do that using single ProjectTo call and only the profiles? The issue is that projection code is separate from multiple profiles projects which use it.

Here is one of profiles:

private static Models.ClientAddressType ConvertAddressType(Database.Entities.Enums.AddressType addressType) =>
    addressType switch
    {
        Database.Entities.Enums.AddressType.Fact => Models.ClientAddressType.Fact,
        Database.Entities.Enums.AddressType.Registered => Models.ClientAddressType.Registered,

        _ => throw new ArgumentException("Unknown address type", nameof(addressType))
    };

CreateProjection<Database.Entities.Data.Client, Models.Client>()
;

CreateProjection<Database.Entities.Data.ClientAddress, Models.ClientAddress>()
    .ForMember(dst => dst.AddressType, opts => opts.MapFrom(src => ConvertAddressType(src.AddressType)))
    .ConstructUsing(src => new Models.ClientAddressCommon())
;

Using var projected = _mapper.ProjectTo<Models.Client>(filtered).Single() gives me correctly filled Client but only with ClientAddressCommon addresses. So how do I convert them on a second step using full power of Map?

UPDATE_01:

According to Lucian Bargaoanu's comment I've made some adjustments:

var projected = _mapper.ProjectTo<Models.Client>(filtered).Single();
var mapped = _mapper.Map<Models.Client>(projected);

But not sure how to proceed. Here is an updated profile:

CreateMap<Models.Client, Models.Client>()
    .AfterMap((src, dst) => Console.WriteLine("CLIENT: {0} -> {1}", src, dst)) // <-- this mapping seems to work
;

CreateMap<Models.ClientAddressCommon, Models.ClientAddress>()
    .ConstructUsing(src => new Models.ClientAddressFact()) // simplified for testing
    .AfterMap((src, dst) => Console.WriteLine("ADR: {0} -> {1}", src, dst)) // <-- this is not outputting
;

Basically I'm now mapping Client to itself just to convert what's left from projection. In this case I need to "aftermap" ClientAddressCommon to ClientAddressFact or ClientAddressRegistered based on AddressType. But looks like the mapping isn't used. What am I missing now?



Solution 1:[1]

So here is what I've came up with. The ClientAddress looks like this now:

public record ClientAddress
{
    public ClientAddressType AddressType { get; init; } // <-- used to differentiate between address types
    public string Address { get; init; }
    public virtual string SpecificValue_Fact { get; init; } // <-- specific for ClientAddressFact
    public virtual string SpecificValue_Registered { get; init; } // <-- specific for ClientAddressRegistered
}

public record ClientAddressFact : ClientAddress
{
}
public record ClientAddressRegistered : ClientAddress
{
}

public enum ClientAddressType
{
    Fact,
    Registered,
}

The profile looks like this:

CreateProjection<Database.Entities.Data.Client, Models.Client>() // <-- project from DB to DTO for the main entity
;

CreateProjection<Database.Entities.Data.ClientAddress, Models.ClientAddress>() // <-- project from TPH entity type to a type which holds all the common properties for all address types
    .ForMember(dst => dst.AddressType, opts => opts.MapFrom(src => ConvertAddressType(src.AddressType)))
;

CreateMap<Models.Client, Models.Client>() // <-- this is needed so AM knows that we need to map a type to itself
;

CreateMap<Models.ClientAddress, Models.ClientAddress>() // <-- changed destination type to itself, since it is the only one available at that moment after projection
    .ConvertUsing<ClientAddressTypeConverter>()
;

CreateMap<Models.ClientAddress, Models.ClientAddressFact>()
;
CreateMap<Models.ClientAddress, Models.ClientAddressRegistered>()
;

An enum conversion helper:

public static Models.ClientAddressType ConvertAddressType(Database.Entities.Enums.AddressType addressType) =>
    addressType switch
    {
        Database.Entities.Enums.AddressType.Fact => Models.ClientAddressType.Fact,
        Database.Entities.Enums.AddressType.Registered => Models.ClientAddressType.Registered,

        _ => throw new ArgumentException("??????????? address type", nameof(addressType))
    };

And this is how conversion is made in the end:

private class ClientAddressTypeConverter : ITypeConverter<Models.ClientAddress, Models.ClientAddress>
{
    public Models.ClientAddress Convert(Models.ClientAddress source, Models.ClientAddress destination, ResolutionContext context) =>
        source.AddressType switch
        {
            Models.ClientAddressType.Fact => context.Mapper.Map<Models.ClientAddressFact>(source),
            Models.ClientAddressType.Registered => context.Mapper.Map<Models.ClientAddressRegistered>(source),

            _ => throw new ArgumentException("Unknown address type")
        };
}

And yes, after projection I still need to re-map again:

var projected = _mapper.ProjectTo<Models.Client>(filtered).Single();
var mapped = _mapper.Map<Models.Client>(projected); // map from itself to itself to convert ClientAddress to corresponding sub-types

This all seems to work but I'm not entirely sure if it's the correct way of doing stuff.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Kasbolat Kumakhov