ASP.NET Core with EF Core - DTO Collection mapping

26,865

Solution 1

AutoMapper is the best solution.

You can do it very easily like this :

    Mapper.CreateMap<Customer, CustomerDto>();
    Mapper.CreateMap<CustomerDto, Customer>();

    Mapper.CreateMap<Person, PersonDto>();
    Mapper.CreateMap<PersonDto, Person>();

Note : Because AutoMapper will automatically map the List<Person> to List<PersonDto>.since they have same name, and there is already a mapping from Person to PersonDto.

If you need to know how to inject it to ASP.net core,you have to see this article : Integrating AutoMapper with ASP.NET Core DI

Auto mapping between DTOs and entities

Mapping using attributes and extension methods

Solution 2

I was struggling with the very same issue for quite some time. After digging through many articles I've came up with my own implementation which I'm sharing with you.

First of all I've created a custom IMemberValueResolver.

using System;
using System.Collections.Generic;
using System.Linq;

namespace AutoMapper
{
    public class CollectionValueResolver<TDto, TItemDto, TModel, TItemModel> : IMemberValueResolver<TDto, TModel, IEnumerable<TItemDto>, IEnumerable<TItemModel>>
        where TDto : class
        where TModel : class
    {
        private readonly Func<TItemDto, TItemModel, bool> _keyMatch;
        private readonly Func<TItemDto, bool> _saveOnlyIf;

        public CollectionValueResolver(Func<TItemDto, TItemModel, bool> keyMatch, Func<TItemDto, bool> saveOnlyIf = null)
        {
            _keyMatch = keyMatch;
            _saveOnlyIf = saveOnlyIf;
        }

        public IEnumerable<TItemModel> Resolve(TDto sourceDto, TModel destinationModel, IEnumerable<TItemDto> sourceDtos, IEnumerable<TItemModel> destinationModels, ResolutionContext context)
        {
            var mapper = context.Mapper;

            var models = new List<TItemModel>();
            foreach (var dto in sourceDtos)
            {
                if (_saveOnlyIf == null || _saveOnlyIf(dto))
                {
                    var existingModel = destinationModels.SingleOrDefault(model => _keyMatch(dto, model));
                    if (EqualityComparer<TItemModel>.Default.Equals(existingModel, default(TItemModel)))
                    {
                        models.Add(mapper.Map<TItemModel>(dto));
                    }
                    else
                    {
                        mapper.Map(dto, existingModel);
                        models.Add(existingModel);
                    }
                }
            }

            return models;
        }
    }
}

Then I configure AutoMapper and add my specific mapping:

cfg.CreateMap<TDto, TModel>()
    .ForMember(dst => dst.DestinationCollection, opts =>
        opts.ResolveUsing(new CollectionValueResolver<TDto, TItemDto, TModel, TItemModel>((src, dst) => src.Id == dst.SomeOtherId, src => !string.IsNullOrEmpty(src.ThisValueShouldntBeEmpty)), src => src.SourceCollection));

This implementation allows me to fully customize my object matching logic due to keyMatch function that is passed in constructor. You can also pass an additional saveOnlyIf function if you for some reason need to verify passed objects if they are suitable for mapping (in my case there were some objects that shouldn't be mapped and added to collection if they didn't pass an extra validation).

Then e.g. in your controller if you want to update your disconnected graph you should do the following:

var model = await Service.GetAsync(dto.Id); // obtain existing object from db
Mapper.Map(dto, model);
await Service.UpdateAsync(model);

This works for me. It's up to you if this implementation suits you better than what author of this question proposed in his edited post:)

Solution 3

First I would recommend using JsonPatchDocument for your update:

    [HttpPatch("{id}")]
    public IActionResult Patch(int id, [FromBody] JsonPatchDocument<CustomerDTO> patchDocument)
    {
        var customer = context.EntityWithRelationships.SingleOrDefault(e => e.Id == id);
        var dto = mapper.Map<CustomerDTO>(customer);
        patchDocument.ApplyTo(dto);
        var updated = mapper.Map(dto, customer);
        context.Entry(entity).CurrentValues.SetValues(updated);
        context.SaveChanges();
        return NoContent();
    }

And secound you should take advantage of AutoMapper.Collections.EFCore. This is how I configured AutoMapper in Startup.cs with an extension method, so that I´m able to call services.AddAutoMapper() without the whole configuration-code:

    public static IServiceCollection AddAutoMapper(this IServiceCollection services)
    {
        var config = new MapperConfiguration(cfg =>
        {
            cfg.AddCollectionMappers();
            cfg.UseEntityFrameworkCoreModel<MyContext>(services);
            cfg.AddProfile(new YourProfile()); // <- you can do this however you like
        });
        IMapper mapper = config.CreateMapper();
        return services.AddSingleton(mapper);
    }

This is what YourProfile should look like:

    public YourProfile()
    {
        CreateMap<Person, PersonDTO>(MemberList.Destination)
            .EqualityComparison((p, dto) => p.Id == dto.Id)
            .ReverseMap();

        CreateMap<Customer, CustomerDTO>(MemberList.Destination)
            .ReverseMap();
    }

I have a similar object-graph an this works fine for me.

EDIT I use LazyLoading, if you don´t you have to explicitly load navigationProperties/Collections.

Share:
26,865
jmw
Author by

jmw

Updated on July 09, 2022

Comments

  • jmw
    jmw almost 2 years

    I am trying to use (POST/PUT) a DTO object with a collection of child objects from JavaScript to an ASP.NET Core (Web API) with an EF Core context as my data source.

    The main DTO class is something like this (simplified of course):

    public class CustomerDto {
        public int Id { get;set }
        ...
        public IList<PersonDto> SomePersons { get; set; }
        ...
    }
    

    What I don't really know is how to map this to the Customer entity class in a way that does not include a lot of code just for finding out which Persons had been added/updated/removed etc.

    I have played around a bit with AutoMapper but it does not really seem to play nice with EF Core in this scenario (complex object structure) and collections.

    After googling for some advice around this I haven't found any good resources around what a good approach would be. My questions is basically: should I redesign the JS-client to not use "complex" DTOs or is this something that "should" be handled by a mapping layer between my DTOs and Entity model or are there any other good solution that I am not aware of?

    I have been able to solve it with both AutoMapper and and by manually mapping between the objects but none of the solutions feels right and quickly become pretty complex with much boilerplate code.

    EDIT:

    The following article describes what I am referring to regarding AutoMapper and EF Core. Its not complicated code but I just want to know if it's the "best" way to manage this.

    (Code from the article is edited to fit the code example above)

    http://cpratt.co/using-automapper-mapping-instances/

    var updatedPersons = new List<Person>();
    foreach (var personDto in customerDto.SomePersons)
    {
        var existingPerson = customer.SomePersons.SingleOrDefault(m => m.Id == pet.Id);
        // No existing person with this id, so add a new one
        if (existingPerson == null)
        {
            updatedPersons.Add(AutoMapper.Mapper.Map<Person>(personDto));
        }
        // Existing person found, so map to existing instance
        else
        {
            AutoMapper.Mapper.Map(personDto, existingPerson);
            updatedPersons.Add(existingPerson);
        }
    }
    // Set SomePersons to updated list (any removed items drop out naturally)
    customer.SomePersons = updatedPersons;
    

    Code above written as a generic extension method.

    public static void MapCollection<TSourceType, TTargetType>(this IMapper mapper, Func<ICollection<TSourceType>> getSourceCollection, Func<TSourceType, TTargetType> getFromTargetCollection, Action<List<TTargetType>> setTargetCollection)
        {
            var updatedTargetObjects = new List<TTargetType>();
            foreach (var sourceObject in getSourceCollection())
            {
                TTargetType existingTargetObject = getFromTargetCollection(sourceObject);
                updatedTargetObjects.Add(existingTargetObject == null
                    ? mapper.Map<TTargetType>(sourceObject)
                    : mapper.Map(sourceObject, existingTargetObject));
            }
            setTargetCollection(updatedTargetObjects);
        }
    

    .....

            _mapper.MapCollection(
                () => customerDto.SomePersons,
                dto => customer.SomePersons.SingleOrDefault(e => e.Id == dto.Id),
                targetCollection => customer.SomePersons = targetCollection as IList<Person>);
    

    Edit:

    One thing I really want is to delcare the AutoMapper configuration in one place (Profile) not have to use the MapCollection() extension every time I use the mapper (or any other solution that requires complicating the mapping code).

    So I created an extension method like this

    public static class AutoMapperExtensions
    {
        public static ICollection<TTargetType> ResolveCollection<TSourceType, TTargetType>(this IMapper mapper,
            ICollection<TSourceType> sourceCollection,
            ICollection<TTargetType> targetCollection,
            Func<ICollection<TTargetType>, TSourceType, TTargetType> getMappingTargetFromTargetCollectionOrNull)
        {
            var existing = targetCollection.ToList();
            targetCollection.Clear();
            return ResolveCollection(mapper, sourceCollection, s => getMappingTargetFromTargetCollectionOrNull(existing, s), t => t);
        }
    
        private static ICollection<TTargetType> ResolveCollection<TSourceType, TTargetType>(
            IMapper mapper,
            ICollection<TSourceType> sourceCollection,
            Func<TSourceType, TTargetType> getMappingTargetFromTargetCollectionOrNull,
            Func<IList<TTargetType>, ICollection<TTargetType>> updateTargetCollection)
        {
            var updatedTargetObjects = new List<TTargetType>();
            foreach (var sourceObject in sourceCollection ?? Enumerable.Empty<TSourceType>())
            {
                TTargetType existingTargetObject = getMappingTargetFromTargetCollectionOrNull(sourceObject);
                updatedTargetObjects.Add(existingTargetObject == null
                    ? mapper.Map<TTargetType>(sourceObject)
                    : mapper.Map(sourceObject, existingTargetObject));
            }
            return updateTargetCollection(updatedTargetObjects);
        }
    }
    

    Then when I create the mappings I us it like this:

        CreateMap<CustomerDto, Customer>()
            .ForMember(m => m.SomePersons, o =>
            {
                o.ResolveUsing((source, target, member, ctx) =>
                {
                    return ctx.Mapper.ResolveCollection(
                        source.SomePersons,
                        target.SomePersons,
                        (targetCollection, sourceObject) => targetCollection.SingleOrDefault(t => t.Id == sourceObject.Id));
                });
            });
    

    Which allow me to use it like this when mapping:

    _mapper.Map(customerDto, customer);
    

    And the resolver takes care of the mapping.

  • jmw
    jmw over 7 years
    the problem is not the mapping code, it's pretty straightforward as you show above. But it is how it works and behaves in combination with EF Core (or other ORMs). See my edit above for more details about what I mean.
  • Sampath
    Sampath over 7 years
    I cannot see any problem about using AutoMapper with any ORM tool. Actually it's pretty standard thing these days.Otherwise you have to map properties one by one manually. Which is very cumbersome task.I'm using aspnetzero as my app development framework.On that framework AutoMapper is the default mapping Api. That framework provides lot of inbuilt extensions with AutoMapper'.They have opensource` product also.if you want to see that I can share that url with you.please let me know if you need it.this is the commercial one :aspnetzero.com
  • jmw
    jmw over 7 years
    So the code I added above is not necessary according to you or is it (or similar) what I have to do to make it work?
  • Sampath
    Sampath over 7 years
    you can use that too.we can write any extension method based on Automapper,On my application we're using attribute based Automapper extensions.usage is like this : var property = input.Property.MapTo<Property>();.so many variations are there.but all are based on AutoMapper.
  • jmw
    jmw over 7 years
    So when you say that you are using an attribute based extension I am really curious how it would be used for the example stated above (and mentioned in the article). (I added an extension method for the code example above in my question)
  • mausworks
    mausworks over 7 years
    Saying AutoMapper is the best solution, is like saying <specific brand of butter> is the best butter. Using AutoMapper is probably a good solution. But you can never prove that it's the best. That aside though, AutoMapper is great, but thats' just my humble opinion.
  • Sampath
    Sampath over 7 years
    updated.please see this section on the above article : Mapping using attributes and extension methods
  • jmw
    jmw over 7 years
    @Sampath it's a good article describing the need for DTOs as well as a simple example of Entity->DTO mapping. Including an Attribute approach. But it does not at all address the question I have: Mapping complex DTOs to ORM Entities. Ex: AutoMapper with EF Core (ORM) collections. I agree with you that AutoMapper is good and that DTOs are useful, that's not the point.
  • Lucian Bargaoanu
    Lucian Bargaoanu over 4 years
  • Joshit
    Joshit over 4 years
    @LucianBargaoanu can you tell me how to use the approach, shown in your link with my configuration? I didn´t get this to work as expected together with the collection package.. Thank you :)
  • Lucian Bargaoanu
    Lucian Bargaoanu over 4 years
    There is an AddAutoMapper overload that allows you to customize the config. I don't know if there is a smoother way.