How to update or delete related collections?

asp.net-core aspnetboilerplate c# entity-framework-core repository-pattern

Question

I'm using Abp version 3.6.2, ASP.Net Core 2.0 and free startup template for multi-page web application. I have following data model:

enter image description here

public class Person : Entity<Guid> {

    public Person() { 
        Phones = new HashSet<PersonPhone>();
    }

    public virtual ICollection<PersonPhone> Phones { get; set; }
    public string Name { get; set; }
}

public class PersonPhone : Entity<Guid> {

    public PersonPhone() { }

    public Guid PersonId { get; set; }
    public virtual Person Person { get; set; }

    public string Number { get; set; }
}

// DB Context and Fluent API
public class ProcimMSDbContext : AbpZeroDbContext<Tenant, Role, User, ProcimMSDbContext> { 

    public virtual DbSet<Person> Persons { get; set; }
    public virtual DbSet<PersonPhone> PersonPhones { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {

        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<PersonPhone>(entity => {
            entity.HasOne(d => d.Person)
                .WithMany(p => p.Phones)
                .HasForeignKey(d => d.PersonId)
                .OnDelete(DeleteBehavior.Cascade)
                .HasConstraintName("FK_PersonPhone_Person");
        });
    }
}

The entities Person and PersonPhone are given here as an example, since there are many "grouped" relationships in the data model that are considered in a single context. In the example above, the relationships between the tables allow to correlate several phones with one person and the associated entities are present in the DTO. The problem is that when creating the Person entity, I can send the phones with the DTO and they will be created with Person as expected. But when I update Person, I get an error:

Abp.AspNetCore.Mvc.ExceptionHandling.AbpExceptionFilter - The instance of 
entity type 'PersonPhone' cannot be tracked because another instance 
with the same key value for {'Id'} is already being tracked. When attaching 
existing entities, ensure that only one entity instance with a given key 
value is attached. Consider using 
'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting 
key values.

In addition to this, the question arises as to how to remove non-existing PersonPhones when updating the Person object? Previously, with the direct use of EntityFramework Core, I did this:

var phones = await _context.PersonPhones
.Where(p => 
    p.PersonId == person.Id && 
    person.Phones
        .Where(i => i.Id == p.Id)
        .Count() == 0)
.ToListAsync();
_context.PersonPhones.RemoveRange(phones);
_context.Person.Update(person);
await _context.SaveChangesAsync();

Question

Is it possible to implement a similar behavior with repository pattern? If "Yes", then is it possible use UoW for this?

P.S.: Application Service

public class PersonAppService : AsyncCrudAppService<Person, PersonDto, Guid, GetAllPersonsDto, CreatePersonDto, UpdatePersonDto, EntityDto<Guid>>, IPersonAppService {

    private readonly IRepository<Person, Guid> _personRepository;

    public PersonAppService(IRepository<Person, Guid> repository) : base(repository) {
        _personRepository = repository;
    }

    public override async Task<PersonDto> Update(UpdatePersonDto input) {

        CheckUpdatePermission();

        var person = await _personRepository
            .GetAllIncluding(
                c => c.Addresses,
                c => c.Emails,
                c => c.Phones
            )
            .Where(c => c.Id == input.Id)
            .FirstOrDefaultAsync();

        ObjectMapper.Map(input, person);

        return await Get(input);
    }
}

Dynamic API calls:

// All O.K.
abp.services.app.person.create({
  "phones": [
    { "number": 1234567890 },
    { "number": 9876543210 }
  ],
  "name": "John Doe"
})
.done(function(response){ 
    console.log(response); 
});

// HTTP 500 and exception in log file
abp.services.app.person.update({
  "phones": [
    { 
        "id": "87654321-dcba-dcba-dcba-000987654321",
        "number": 1234567890 
    }
  ],
  "id":"12345678-abcd-abcd-abcd-123456789000",
  "name": "John Doe"
})
.done(function(response){ 
    console.log(response); 
});

Update

At the moment, to add new entities and update existing ones, I added the following AutoMapper profile:

public class PersonMapProfile : Profile {

  public PersonMapProfile () { 
    CreateMap<UpdatePersonDto, Person>();
    CreateMap<UpdatePersonDto, Person>()
      .ForMember(x => x.Phones, opt => opt.Ignore())
        .AfterMap((dto, person) => AddOrUpdatePhones(dto, person));
  }

  private void AddOrUpdatePhones(UpdatePersonDto dto, Person person) {
    foreach (UpdatePersonPhoneDto phoneDto in dto.Phones) {
      if (phoneDto.Id == default(Guid)) {
        person.Phones.Add(Mapper.Map<PersonPhone>(phoneDto));
      }
      else {
        Mapper.Map(phoneDto, person.Phones.SingleOrDefault(p => p.Id == phoneDto.Id));
      }
    }
  }
}

But there is a problem with removed objects, that is, with objects that are in the database, but not in the DTO. To delete them, i'm in a loop compare objects and manually delete them from the database in the application service:

public override async Task<PersonDto> Update(UpdatePersonDto input) {

    CheckUpdatePermission();

    var person = await _personRepository
        .GetAllIncluding(
            c => c.Phones
        )
        .FirstOrDefaultAsync(c => c.Id == input.Id);

    ObjectMapper.Map(input, person);

    foreach (var phone in person.Phones.ToList()) {
        if (input.Phones.All(x => x.Id != phone.Id)) {
            await _personAddressRepository.DeleteAsync(phone.Id);
        }
    }

    await CurrentUnitOfWork.SaveChangesAsync();

    return await Get(input);
}

Here there is another problem: the object, which returned from Get, contains all entities (deleted, added, updated) are simultaneously. I also tried to use synchronous variants of methods and opened a separate transaction with UnitOfWorkManager, like so:

public override async Task<PersonDto> Update(UpdatePersonDto input) {

    CheckUpdatePermission();

    using (var uow = UnitOfWorkManager.Begin()) {

        var person = await _companyRepository
            .GetAllIncluding(
                c => c.Phones
            )
            .FirstOrDefaultAsync(c => c.Id == input.Id);

        ObjectMapper.Map(input, person);

        foreach (var phone in person.Phones.ToList()) {
            if (input.Phones.All(x => x.Id != phone.Id)) {
                await _personAddressRepository.DeleteAsync(phone.Id);
            }
        }

        uow.Complete();
    }

    return await Get(input);
}

but this did not help. When Get is called again on the client side, the correct object is returned. I assume that the problem is either in the cache or in the transaction. What am I doing wrong?

1
1
6/8/2018 9:14:09 AM

Accepted Answer

At the moment I solved this problem. In the beginning it is necessary to disable collections mapping, because AutoMapper rewrites them in consequence of which EntityFramework defines these collections as new entities and tries to add them to the database. To disable collections mapping, it needs to create a class that inherits from AutoMapper.Profile:

using System;
using System.Linq;
using Abp.Domain.Entities;
using AutoMapper;

namespace ProjectName.Persons.Dto {
    public class PersonMapProfile : Profile {
        public PersonMapProfile() {

            CreateMap<UpdatePersonDto, Person>();
            CreateMap<UpdatePersonDto, Person>()
                .ForMember(x => x.Phones, opt => opt.Ignore())
                    .AfterMap((personDto, person) => 
                         AddUpdateOrDelete(personDto, person));
        }

        private void AddUpdateOrDelete(UpdatePersonDto dto, Person person) {

             person.Phones
            .Where(phone =>
                !dto.Phones
                .Any(phoneDto => phoneDto.Id == phone.Id)
            )
            .ToList()
            .ForEach(deleted =>
                person.Phones.Remove(deleted)
            );

            foreach (var phoneDto in dto.Phones) {
                if (phoneDto.Id == default(Guid)) {
                    person.Phones
                    .Add(Mapper.Map<PersonPhone>(phoneDto));
                }
                else {
                    Mapper.Map(phoneDto, 
                        person.Phones.
                        SingleOrDefault(c => c.Id == phoneDto.Id));
                }
            }
        }
    }
}

In the example above, we ignore the collection mapping and use the callback function to add, update or delete phones. Now the error about the impossibility of tracking the entity no longer arises. But if you run this code now, you can see that the returned object has both added entities and removed too. This is due to the fact that by default Abp uses UnitOfWork for application service methods. Therefore, you must disable this default behavior and use an explicit transaction.

using Abp.Application.Services.Dto;
using Abp.Application.Services;
using Abp.Domain.Repositories;
using Abp.Domain.Uow;
using Microsoft.EntityFrameworkCore;
using ProjectName.Companies.Dto;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System;

namespace ProjectName.Persons {
    public class PersonAppService : AsyncCrudAppService<Person, PersonDto, Guid, GetAllPersonsDto, CreatePersonDto, UpdatePersonDto, EntityDto<Guid>>, IPersonAppService {

        private readonly IRepository<Person, Guid> _personRepository;
        private readonly IRepository<PersonPhone, Guid> _personPhoneRepository;

        public PersonAppService(
            IRepository<Person, Guid> repository,
            IRepository<PersonPhone, Guid> personPhoneRepository) : base(repository) {
            _personRepository = repository;
            _personPhoneRepository = personPhoneRepository;
        }

        [UnitOfWork(IsDisabled = true)]
        public override async Task<PersonDto> Update(UpdatePersonDto input) {

            CheckUpdatePermission();

            using (var uow = UnitOfWorkManager.Begin()) {

                var person = await _personRepository
                    .GetAllIncluding(
                        c => c.Phones
                    )
                    .FirstOrDefaultAsync(c => c.Id == input.Id);

                ObjectMapper.Map(input, person);

                uow.Complete();
            }

            return await Get(input);
        }
    }
}

It is possible that such code is not optimal or violates any principles. In this case, I will be nice to know how to do it.

0
7/3/2018 7:39:22 AM


Related Questions





Related

Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow
Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow