EF Core — добавление/обновление объекта и добавление/обновление/удаление дочерних объектов в одном запросе

Я борюсь с тем, что казалось парой основных операций.

Скажем, у меня есть класс с именем Master:

public class Master
{
    public Master()
    {
        Children = new List<Child>();
    }

    public int Id { get; set; }
    public string SomeProperty { get; set; }

    [ForeignKey("SuperMasterId")]
    public SuperMaster SuperMaster { get; set; }
    public int SuperMasterId { get; set; }

    public ICollection<Child> Children { get; set; }
}

public class Child 
{
    public int Id { get; set; }
    public string SomeDescription { get; set; }
    public decimal Count{ get; set; }

    [ForeignKey("RelatedEntityId")]
    public RelatedEntity RelatedEntity { get; set; }
    public int RelatedEntityId { get; set; }

    [ForeignKey("MasterId")]
    public Master Master { get; set; }
    public int MasterId { get; set; }
}

У нас есть такое действие контроллера:

public async Task<OutputDto> Update(UpdateDto updateInput)
{
    // First get a real entity by Id from the repository
    // This repository method returns: 
    // Context.Masters
    //    .Include(x => x.SuperMaster)
    //    .Include(x => x.Children)
    //    .ThenInclude(x => x.RelatedEntity)
    //    .FirstOrDefault(x => x.Id == id)
    Master entity = await _masterRepository.Get(input.Id);

    // Update properties
    entity.SomeProperty = "Updated value";
    entity.SuperMaster.Id = updateInput.SuperMaster.Id;

    foreach (var child in input.Children)
    {
        if (entity.Children.All(x => x.Id != child.Id))
        {
            // This input child doesn't exist in entity.Children -- add it
            // Mapper.Map uses AutoMapper to map from the input DTO to entity
            entity.Children.Add(Mapper.Map<Child>(child));
            continue;
        }

        // The input child exists in entity.Children -- update it
        var oldChild = entity.Children.FirstOrDefault(x => x.Id == child.Id);
        if (oldChild == null)
        {
            continue;
        }

        // The mapper will also update child.RelatedEntity.Id
        Mapper.Map(child, oldChild);
    }

    foreach (var child in entity.Children.Where(x => x.Id != 0).ToList())
    {
        if (input.Children.All(x => x.Id != child.Id))
        {
            // The child doesn't exist in input anymore, mark it for deletion
            child.Id = -1;
        }
    }

    entity = await _masterRepository.UpdateAsync(entity);

    // Use AutoMapper to map from entity to DTO
    return MapToEntityDto(entity);
}

Теперь метод репозитория (MasterRepository):

public async Task<Master> UpdateAsync(Master entity)
{
    var superMasterId = entity.SuperMaster.Id;

    // Make sure SuperMaster properties are updated in case the superMasterId is changed
    entity.SuperMaster = await Context.SuperMasters
        .FirstOrDefaultAsync(x => x.Id == superMasterId);

    // New and updated children, skip deleted
    foreach (var child in entity.Children.Where(x => x.Id != -1))
    {
        await _childRepo.InsertOrUpdateAsync(child);
    }

    // Handle deleted children
    foreach (var child in entity.Children.Where(x => x.Id == -1))
    {
        await _childRepo.DeleteAsync(child);
        entity.Children.Remove(child);
    }

    return entity;
}

И, наконец, соответствующий код из ChildrenRepository:

public async Task<Child> InsertOrUpdateAsync(Child entity)
{
    if (entity.Id == 0)
    {
        return await InsertAsync(entity, parent);
    }

    var relatedId = entity.RelatedEntity.Id;
    entity.RelatedEntity = await Context.RelatedEntities
        .FirstOrDefaultAsync(x => x.Id == relatedId);

    // We have already updated child properties in the controller method 
    // and it's expected that changed entities are marked as changed in EF change tracker
    return entity;
}

public async Task<Child> InsertAsync(Child entity)
{
    var relatedId = entity.RelatedEntity.Id;
    entity.RelatedEntity = await Context.RelatedEntities
        .FirstOrDefaultAsync(x => x.Id == relatedId);

    entity = Context.Set<Child>().Add(entity).Entity;

    // We need the entity Id, hence the call to SaveChanges
    await Context.SaveChangesAsync();
    return entity;
}

Свойство Context на самом деле равно DbContext, и транзакция запускается в фильтре действий. Если действие вызывает исключение, фильтр действий выполняет откат, а если нет, вызывает SaveChanges.

Отправляемый объект ввода выглядит следующим образом:

{
  "someProperty": "Some property",
  "superMaster": {
     "name": "SuperMaster name",
     "id": 1
  },
  "children": [
  {
    "relatedEntity": {
      "name": "RelatedEntity name",
      "someOtherProp": 20,
      "id": 1
    },
    "count": 20,
    "someDescription": "Something"
  }],
  "id": 10
}

Таблица Masters в настоящее время имеет одну запись с идентификатором 10. У нее нет дочерних элементов.

Выбрасывается исключение:

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded.

Что тут происходит? Я думал, что EF должен отслеживать изменения, и это включает в себя знание того, что мы вызвали SaveChanges в этом внутреннем методе.

EDIT Удаление этого вызова SaveChanges ничего не меняет. Кроме того, я не смог найти инструкции SQL INSERT или UPDATE, сгенерированные EF, при просмотре того, что происходит в SQL Server Profiler.

EDIT2 Оператор INSERT присутствует при вызове SaveChanges, но по-прежнему отсутствует оператор UPDATE для главного объекта.


person Dejan Janjušević    schedule 20.01.2018    source источник
comment
Я думаю, что асинхронные операции не будут работать для одного и того же контекста БД.   -  person Varun    schedule 20.01.2018
comment
Какой-то оператор UPDATE генерирует это исключение (поскольку обновленные строки равны 0). Я не совсем уверен, что происходит, но если вы откроете окно профилировщика сервера и зафиксируете, что это за оператор UPDATE, это должно помочь вам понять это.   -  person Pace    schedule 20.01.2018
comment
Поскольку вы устанавливаете значение Id равным -1, EF выдает операторы DELETE, которые не влияют ни на одну строку. Вы никогда не должны изменять значения первичного ключа.   -  person Gert Arnold    schedule 21.01.2018


Ответы (2)


Как обычно, публикация этого вопроса в StackOverflow помогла мне решить проблему. Первоначально код не выглядел так, как в вопросе выше, но я скорее исправлял код, когда писал вопрос.

Прежде чем написать вопрос, я потратил почти день, пытаясь понять, в чем проблема, и поэтому я пробовал разные вещи, такие как воссоздание экземпляров сущностей и их присоединение вручную, пометка некоторых сущностей как неизмененных/модифицированных, использование AsNoTracking или даже полное отключение автоматического отслеживание изменений для всех сущностей и пометка всех их добавленными или измененными вручную.

Оказалось, что код, вызвавший эту проблему, находился в частном методе этого дочернего репозитория, который я пропустил, так как не думал, что это имеет значение. Однако это действительно не имело бы значения, если бы я не забыл удалить из него некоторый код ручного отслеживания изменений, который в основном возился с автоматическим отслеживанием изменений EF и приводил к его неправильному поведению.

Но, благодаря StackOverflow, проблема решилась. Когда вы говорите с кем-то о проблеме, вам нужно заново проанализировать ее самому, чтобы быть в состоянии объяснить все ее маленькие кусочки, чтобы тот, с кем вы говорите (в данном случае, сообщество SO), понял ее. Во время повторного анализа вы замечаете все маленькие проблемные моменты, и тогда становится легче диагностировать проблему.

В любом случае, если кого-то заинтересует этот вопрос из-за заголовка, через поиск Google или w/e, вот несколько ключевых моментов:

  • Если вы обновляете объекты на нескольких уровнях, всегда вызывайте .Include, чтобы включить все связанные свойства навигации при получении существующего объекта. Это сделает их все загруженными в систему отслеживания изменений, и вам не нужно будет прикреплять/отмечать их вручную. После того, как вы закончите обновление, вызов SaveChanges правильно сохранит все ваши изменения.

  • Не используйте AutoMapper для объекта верхнего уровня, когда вам нужно обновить дочерние объекты, особенно если вам нужно реализовать некоторую дополнительную логику при обновлении дочерних объектов.

  • Никогда не обновляйте первичные ключи, как я пытался при установке Id в -1, или как я пытался сделать в этой строке прямо здесь, в методе обновления контроллера:

    // The mapper will also update child.RelatedEntity.Id Mapper.Map(child, oldChild);

  • Если вам нужно обработать удаленные элементы, лучше обнаружить их и сохранить в отдельном списке, а затем вручную вызвать метод удаления репозитория для каждого из них, где метод удаления репозитория будет содержать некоторую возможную дополнительную логику относительно связанных сущностей.

  • Если вам нужно изменить первичный ключ связанного объекта, вам нужно сначала удалить этот связанный объект из отношения и просто добавить новый с обновленным ключом.

Итак, вот обновленное действие контроллера с опущенными нулевыми проверками и проверками безопасности:

public async Task<OutputDto> Update(InputDto input)
{
    // First get a real entity by Id from the repository
    // This repository method returns: 
    // Context.Masters
    //    .Include(x => x.SuperMaster)
    //    .Include(x => x.Children)
    //    .ThenInclude(x => x.RelatedEntity)
    //    .FirstOrDefault(x => x.Id == id)
    Master entity = await _masterRepository.Get(input.Id);

    // Update the master entity properties manually
    entity.SomeProperty = "Updated value";

    // Prepare a list for any children with modified RelatedEntity
    var changedChildren = new List<Child>();

    foreach (var child in input.Children)
    {
        // Check to see if this is a new child item
        if (entity.Children.All(x => x.Id != child.Id))
        {
            // Map the DTO to child entity and add it to the collection
            entity.Children.Add(Mapper.Map<Child>(child));
            continue;
        }

        // Check to see if this is an existing child item
        var existingChild = entity.Children.FirstOrDefault(x => x.Id == child.Id);
        if (existingChild == null)
        {
            continue;
        }

        // Check to see if the related entity was changed
        if (existingChild.RelatedEntity.Id != child.RelatedEntity.Id)
        {
            // It was changed, add it to changedChildren list
            changedChildren.Add(existingChild);
            continue;
        }

        // It's safe to use AutoMapper to map the child entity and avoid updating properties manually, 
        // provided that it doesn't have child-items of their own
        Mapper.Map(child, existingChild);
    }

    // Find which of the child entities should be deleted
    // entity.IsTransient() is an extension method which returns true if the entity has just been added
    foreach (var child in entity.Children.Where(x => !x.IsTransient()).ToList())
    {
        if (input.Children.Any(x => x.Id == child.Id))
        {
            continue;
        }

        // We don't have this entity in the list sent by the client.
        // That means we should delete it
        await _childRepository.DeleteAsync(child);
        entity.Children.Remove(child);
    }

    // Parse children entities with modified related entities
    foreach (var child in changedChildren)
    {
        var newChild = input.Children.FirstOrDefault(x => x.Id == child.Id);

        // Delete the existing one
        await _childRepository.DeleteAsync(child);
        entity.Children.Remove(child);

        // Add the new one
        // It's OK to change the primary key here, as this one is a DTO, not a tracked entity,
        // and besides, if the keys are autogenerated by the database, we can't have anything but 0 for a new entity
        newChild.Id = 0;
        entity.Djelovi.Add(Mapper.Map<Child>(newChild)); 
    }

    // And finally, call the repository update and return the result mapped to DTO
    entity = await _repository.UpdateAsync(entity);
    return MapToEntityDto(entity);
}
person Dejan Janjušević    schedule 21.01.2018
comment
Это довольно распространенное явление, и вы увидите, что эксперты называют это отладкой резиновой утки. Звучит глупо, но на самом деле это так: en.wikipedia.org/wiki/Rubber_duck_debugging Рад, что вы смогли решить проблему. Отличная работа. - person PaulG; 28.07.2020
comment
Сколько вызовов базы данных он делает, чтобы 1) добавить 1 дочерний элемент 2) обновить 2 дочерних элемента 3) удалить 3 дочерних элемента - person Maulik Modi; 05.07.2021
comment
@MaulikModi Я не профилировал его, но, вероятно, по одному для каждой операции, плюс есть первый, который читает сущность. Вероятно, это вопрос к участникам EF, попробуйте задать его на /dotnet/efcore github. - person Dejan Janjušević; 06.07.2021

Используйте эту общую подпрограмму, чтобы отметить дочернее состояние, простое в использовании.

Примечания:

  • PromatCon: объект сущности
  • amList: дочерний список, который вы хотите добавить или изменить.
  • rList: дочерний список, который вы хотите удалить
updatechild(objCas.ECC_Decision, PromatCon.ECC_Decision.Where(c => c.rid == objCas.rid & !objCas.ECC_Decision.Select(x => x.dcid).Contains(c.dcid)).toList())
public void updatechild<Ety>(ICollection<Ety> amList, ICollection<Ety> rList)
{
        foreach (var obj in amList)
        {
            var x = PromatCon.Entry(obj).GetDatabaseValues();
            if (x == null)
                PromatCon.Entry(obj).State = EntityState.Added;
            else
                PromatCon.Entry(obj).State = EntityState.Modified;
        }
        foreach (var obj in rList.ToList())
            PromatCon.Entry(obj).State = EntityState.Deleted;
}
PromatCon.SaveChanges()
person Basil    schedule 26.12.2019