0

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?

Kasbolat Kumakhov
  • 607
  • 1
  • 11
  • 30
  • You simply call `Map` passing the result from `ProjectTo.Single()`. All the profiles are part of the same configuration, the maps are global, so it's doesn't matter in which profile they're defined. – Lucian Bargaoanu Mar 25 '22 at 12:04
  • Sorry, I'm not sure I follow. Do you suggest something like `_mapper.Map(projected)`? Meaning map `Client` to `Client` again? Tried that as an experiment but didn't seem to do anything. – Kasbolat Kumakhov Mar 25 '22 at 12:38
  • Yes. It does what you configured it to do :) You can always map only the addresses if that seems easier. – Lucian Bargaoanu Mar 25 '22 at 12:44
  • Thanks, but actually the idea was to separate data query from data mapping and put all mapping 'logic' inside profiles. Manually re-mapping after projecting seems to contradict it. There are more subitems than just `ClientAddress`. I was hoping if it's possible using profiles along. – Kasbolat Kumakhov Mar 25 '22 at 13:00
  • With AM you have to clearly separate server evaluation from client evaluation. – Lucian Bargaoanu Mar 25 '22 at 13:39
  • Yep. Wanted to separate that using only the profiles but seems it won't work. I've updated the initial post based on your suggestion. Not sure what I missed. – Kasbolat Kumakhov Mar 25 '22 at 14:45

1 Answers1

0

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.

Kasbolat Kumakhov
  • 607
  • 1
  • 11
  • 30
  • The enum conversion for `AddressType` is not needed, it does that by default. – Lucian Bargaoanu Mar 28 '22 at 13:52
  • Actually without it I get an error saying something about `42846` and an inability to cast `address_type` to integer (sorry, the error text is localized). Perhaps that's because `AddressType` is not just `enum` in C# but also in the database itself (I use PostgreSQL with `enum` support). – Kasbolat Kumakhov Mar 29 '22 at 10:54
  • [It works](https://github.com/AutoMapper/AutoMapper/blob/a00e68fc364f2f8c4987a9c19a45a6b2d7afe998/src/IntegrationTests/BuiltInTypes/Enums.cs#L103) with SQL Server. But I don't even understand how that function works with `ProjectTo`. Maybe your provider does things differently :) – Lucian Bargaoanu Mar 29 '22 at 11:02
  • Perhaps that's because PostgreSQL treats `enum`'s as a full type instead of just an integer. And if so than conversion is not possible without full type casting I guess. I'm using [Npgsql Entity Framework Core Provider](https://www.npgsql.org/efcore/) – Kasbolat Kumakhov Mar 29 '22 at 13:00