EF中如何在更新父实体时添加/更新子实体

213

这两个实体之间有一对多的关系(由Code First Fluent API构建)。

public class Parent
{
    public Parent()
    {
        this.Children = new List<Child>();
    }

    public int Id { get; set; }

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

public class Child
{
    public int Id { get; set; }

    public int ParentId { get; set; }

    public string Data { get; set; }
}

在我的WebApi控制器中,我有创建父实体的操作(可以正常工作),以及更新父实体的操作(存在一些问题)。更新操作如下:

public void Update(UpdateParentModel model)
{
    //what should be done here?
}

目前我有两个想法:

  1. 通过 model.Id 获取名为 existing 的父实体,并逐一将 model 中的值赋给该实体。这听起来很愚蠢。而且在 model.Children 中,我不知道哪个子项是新的,哪个子项被修改了(甚至被删除了)。

  2. 通过 model 创建一个新的父实体,并将其附加到 DbContext 上并保存。但是 DbContext 怎么知道子项的状态(新增/删除/修改)呢?

实现这个功能的正确方式是什么?


1
考虑使用https://github.com/WahidBitar/EF-Core-Simple-Graph-Update。 这个工具对我来说效果很好。 - Michael Freidgeim
14个回答

290

因为发布到 WebApi 控制器的模型与任何 Entity Framework(EF)上下文都没有关联,所以唯一的选择是从数据库中加载对象图(包括父项及其子项),并比较哪些子项已添加、删除或更新。(除非您在分离状态期间(在浏览器或其他地方)使用自己的跟踪机制跟踪更改,但我认为这比以下方法更复杂。)可能会像这样:

public void Update(UpdateParentModel model)
{
    var existingParent = _dbContext.Parents
        .Where(p => p.Id == model.Id)
        .Include(p => p.Children)
        .SingleOrDefault();

    if (existingParent != null)
    {
        // Update parent
        _dbContext.Entry(existingParent).CurrentValues.SetValues(model);

        // Delete children
        foreach (var existingChild in existingParent.Children.ToList())
        {
            if (!model.Children.Any(c => c.Id == existingChild.Id))
                _dbContext.Children.Remove(existingChild);
        }

        // Update and Insert children
        foreach (var childModel in model.Children)
        {
            var existingChild = existingParent.Children
                .Where(c => c.Id == childModel.Id && c.Id != default(int))
                .SingleOrDefault();

            if (existingChild != null)
                // Update child
                _dbContext.Entry(existingChild).CurrentValues.SetValues(childModel);
            else
            {
                // Insert child
                var newChild = new Child
                {
                    Data = childModel.Data,
                    //...
                };
                existingParent.Children.Add(newChild);
            }
        }

        _dbContext.SaveChanges();
    }
}

...CurrentValues.SetValues 方法可以接受任何对象,并根据属性名称将属性值映射到附加实体上。如果模型中的属性名称与实体中的名称不同,则无法使用此方法,必须逐个分配这些值。


61
为什么EF没有更“优秀”的方法呢?我认为EF可以检测到子项是否被修改/删除/添加。在我看来,你上面的代码可以成为EF框架的一部分,并成为一个更通用的解决方案。 - Cheng Chen
8
@DannyChen提到,EF确实需要更加方便地支持更新失联的实体(https://entityframework.codeplex.com/workitem/864),但这仍然不是框架的一部分。目前,您只能尝试在该codeplex工作项中提到的第三方库“GraphDiff”,或者像我上面回答的那样编写手动代码。 - Slauma
10
需要补充一点:在更新和插入子项的foreach循环中,你不能使用existingParent.Children.Add(newChild),因为这样现有子项的linq搜索会返回最近添加的实体,导致该实体被更新。你需要先将子项插入一个临时列表,然后再进行添加。 - Erre Efe
4
@RandolfRincónFadul 我刚刚遇到了这个问题。我的解决方法是在 existingChild LINQ 查询的 where 子句中进行更改:.Where(c => c.ID == childModel.ID && c.ID != default(int)) - Gavin Ward
3
你在谈论的2.2修复是什么? - Jan Paolo Go
显示剩余10条评论

33

好的各位,我曾经有过这个答案,但在途中丢失了它。当你知道有更好的方法却无法记住或找到它时,这是绝对的折磨!它非常简单。我已经多次测试过。

var parent = _dbContext.Parents
  .Where(p => p.Id == model.Id)
  .Include(p => p.Children)
  .FirstOrDefault();

parent.Children = _dbContext.Children.Where(c => <Query for New List Here>);
_dbContext.Entry(parent).State = EntityState.Modified;

_dbContext.SaveChanges();

你可以用一个新的列表替换整个列表!SQL代码将根据需要删除和添加实体。不必担心这个问题。一定要包含子集合,否则不行。祝好运!


1
@pantonis 我包含子集合是为了可以进行编辑加载。如果我依赖于延迟加载来解决它,那么它就不起作用。我设置了子项(一次),因为我可以简单地替换列表而不是手动删除和添加集合中的项目,这样EntityFramework将为我添加和删除项目。关键是将实体的状态设置为已修改,并允许EntityFramework完成繁重的工作。 - Charles McIntosh
2
@CharlesMcIntosh _dbContext.Children.Where(c => <Query for New List Here>); 这里的不完整代码是什么? - pantonis
1
@pantonis @CharlesMcIntosh 不需要再次请求子元素,这只是他决定重新分配给 .Children 的方式。parent.Children = 这一行可以赋值给任何新创建的子元素。 - zola25
1
它将外键设置为NULL而不是删除它们。我该怎么解决这个问题?我想从数据库中删除它们。 - Mahmood Jenami
我认为这个语句“SQL代码将根据需要删除和添加实体”是不正确的。实际上,所有原始(被跟踪的)子项都将被删除,并创建新的子项。这可能是可以接受的,具体取决于您想要什么。 - O'Rooney
显示剩余8条评论

15

我一直在试着做类似这样的事情...

protected void UpdateChildCollection<Tparent, Tid , Tchild>(Tparent dbItem, Tparent newItem, Func<Tparent, IEnumerable<Tchild>> selector, Func<Tchild, Tid> idSelector) where Tchild : class
    {
        var dbItems = selector(dbItem).ToList();
        var newItems = selector(newItem).ToList();

        if (dbItems == null && newItems == null)
            return;

        var original = dbItems?.ToDictionary(idSelector) ?? new Dictionary<Tid, Tchild>();
        var updated = newItems?.ToDictionary(idSelector) ?? new Dictionary<Tid, Tchild>();

        var toRemove = original.Where(i => !updated.ContainsKey(i.Key)).ToArray();
        var removed = toRemove.Select(i => DbContext.Entry(i.Value).State = EntityState.Deleted).ToArray();

        var toUpdate = original.Where(i => updated.ContainsKey(i.Key)).ToList();
        toUpdate.ForEach(i => DbContext.Entry(i.Value).CurrentValues.SetValues(updated[i.Key]));

        var toAdd = updated.Where(i => !original.ContainsKey(i.Key)).ToList();
        toAdd.ForEach(i => DbContext.Set<Tchild>().Add(i.Value));
    }
您可以使用类似以下的方式调用该函数:

which you can call with something like:


UpdateChildCollection(dbCopy, detached, p => p.MyCollectionProp, collectionItem => collectionItem.Id)

如果子类型上还有需要更新的集合属性,那么这种方法可能就行不通了。我考虑尝试解决这个问题,通过传递一个IRepository(带有基本CRUD方法),该接口将负责自己调用UpdateChildCollection方法。而不是直接调用DbContext.Entry。

我不知道这样做是否能够在大规模情况下正常运行,但我不知道还能做什么来解决这个问题。


1
很棒的解决方案!但是如果添加多个新项目,则更新的字典不能有两个零ID。需要一些解决方法。而且,如果关系是N -> N,则也会失败,实际上,该项已添加到数据库,但N -> N表未被修改。 - RenanStr
1
toAdd.ForEach(i => (selector(dbItem) as ICollection<Tchild>).Add(i.Value)); 应该可以解决 n -> n 的问题。 - RenanStr

12
如果您正在使用EntityFrameworkCore,您可以在控制器的post操作中执行以下操作(Attach方法会递归附加导航属性,包括集合):
_context.Attach(modelPostedToController);

IEnumerable<EntityEntry> unchangedEntities = _context.ChangeTracker.Entries().Where(x => x.State == EntityState.Unchanged);

foreach(EntityEntry ee in unchangedEntities){
     ee.State = EntityState.Modified;
}

await _context.SaveChangesAsync();

假设每个已更新的实体都具有客户端提交的所有属性集(例如,对实体的部分更新将无法正常工作)。

您还需要确保为此操作使用新的/专用的实体框架数据库上下文。


6
它不会从父集合中删除已移除的实体。 - Alex Garcia

6
public async Task<IHttpActionResult> PutParent(int id, Parent parent)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            if (id != parent.Id)
            {
                return BadRequest();
            }

            db.Entry(parent).State = EntityState.Modified;

            foreach (Child child in parent.Children)
            {
                db.Entry(child).State = child.Id == 0 ? EntityState.Added : EntityState.Modified;
            }

            try
            {
                await db.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ParentExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return Ok(db.Parents.Find(id));
        }

这是我解决问题的方法。这样,EF就知道哪些需要添加,哪些需要更新。

2
删除在哪里?此外,这是否适用于客户端生成的GUIDS? - MattoMK
如果您正在使用客户端生成的 GUID,并且实体处于断开/分离状态怎么办?因此,DTO 中的每个子项都将具有可能存在或不存在于数据库中(更新、添加)的 GUID。但是,数据库中可能存在不在列表中的子项,应该将其删除。 - MattoMK

5

我来翻译这段内容...

private void Reconcile<T>(DbContext context,
    IReadOnlyCollection<T> oldItems,
    IReadOnlyCollection<T> newItems,
    Func<T, T, bool> compare)
{
    var itemsToAdd = new List<T>();
    var itemsToRemove = new List<T>();

    foreach (T newItem in newItems)
    {
        T oldItem = oldItems.FirstOrDefault(arg1 => compare(arg1, newItem));

        if (oldItem == null)
        {
            itemsToAdd.Add(newItem);
        }
        else
        {
            context.Entry(oldItem).CurrentValues.SetValues(newItem);
        }
    }

    foreach (T oldItem in oldItems)
    {
        if (!newItems.Any(arg1 => compare(arg1, oldItem)))
        {
            itemsToRemove.Add(oldItem);
        }
    }

    foreach (T item in itemsToAdd)
        context.Add(item);

    foreach (T item in itemsToRemove)
        context.Remove(item);
}

需要更新的项目呢? - MattoMK
这个在子项目中如何工作? - undefined

2

因为我不喜欢重复复杂的逻辑,所以这里提供了Slauma解决方案的通用版本。

这是我的更新方法。请注意,在分离的情况下,有时您的代码将读取数据然后更新它,因此并非总是分离的。

public async Task UpdateAsync(TempOrder order)
{
    order.CheckNotNull(nameof(order));
    order.OrderId.CheckNotNull(nameof(order.OrderId));

    order.DateModified = _dateService.UtcNow;

    if (_context.Entry(order).State == EntityState.Modified)
    {
        await _context.SaveChangesAsync().ConfigureAwait(false);
    }
    else // Detached.
    {
        var existing = await SelectAsync(order.OrderId!.Value).ConfigureAwait(false);
        if (existing != null)
        {
            order.DateModified = _dateService.UtcNow;
            _context.TrackChildChanges(order.Products, existing.Products, (a, b) => a.OrderProductId == b.OrderProductId);
            await _context.SaveChangesAsync(order, existing).ConfigureAwait(false);
        }
    }
}

CheckNotNull在这里定义。

创建这些扩展方法。

/// <summary>
/// Tracks changes on childs models by comparing with latest database state.
/// </summary>
/// <typeparam name="T">The type of model to track.</typeparam>
/// <param name="context">The database context tracking changes.</param>
/// <param name="childs">The childs to update, detached from the context.</param>
/// <param name="existingChilds">The latest existing data, attached to the context.</param>
/// <param name="match">A function to match models by their primary key(s).</param>
public static void TrackChildChanges<T>(this DbContext context, IList<T> childs, IList<T> existingChilds, Func<T, T, bool> match)
    where T : class
{
    context.CheckNotNull(nameof(context));
    childs.CheckNotNull(nameof(childs));
    existingChilds.CheckNotNull(nameof(existingChilds));

    // Delete childs.
    foreach (var existing in existingChilds.ToList())
    {
        if (!childs.Any(c => match(c, existing)))
        {
            existingChilds.Remove(existing);
        }
    }

    // Update and Insert childs.
    var existingChildsCopy = existingChilds.ToList();
    foreach (var item in childs.ToList())
    {
        var existing = existingChildsCopy
            .Where(c => match(c, item))
            .SingleOrDefault();

        if (existing != null)
        {
            // Update child.
            context.Entry(existing).CurrentValues.SetValues(item);
        }
        else
        {
            // Insert child.
            existingChilds.Add(item);
            // context.Entry(item).State = EntityState.Added;
        }
    }
}

/// <summary>
/// Saves changes to a detached model by comparing it with the latest data.
/// </summary>
/// <typeparam name="T">The type of model to save.</typeparam>
/// <param name="context">The database context tracking changes.</param>
/// <param name="model">The model object to save.</param>
/// <param name="existing">The latest model data.</param>
public static void SaveChanges<T>(this DbContext context, T model, T existing)
    where T : class
{
    context.CheckNotNull(nameof(context));
    model.CheckNotNull(nameof(context));

    context.Entry(existing).CurrentValues.SetValues(model);
    context.SaveChanges();
}

/// <summary>
/// Saves changes to a detached model by comparing it with the latest data.
/// </summary>
/// <typeparam name="T">The type of model to save.</typeparam>
/// <param name="context">The database context tracking changes.</param>
/// <param name="model">The model object to save.</param>
/// <param name="existing">The latest model data.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns></returns>
public static async Task SaveChangesAsync<T>(this DbContext context, T model, T existing, CancellationToken cancellationToken = default)
    where T : class
{
    context.CheckNotNull(nameof(context));
    model.CheckNotNull(nameof(context));

    context.Entry(existing).CurrentValues.SetValues(model);
    await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}

1

所以,我最终设法让它工作了,尽管不是完全自动化的。
请注意 AutoMapper <3。它处理了所有属性的映射,因此您不必手动处理。而且,如果以一种从一个对象映射到另一个对象的方式使用它,则它仅更新属性,并将已更改的属性标记为 EF 中的 Modified,这正是我们想要的。
如果您使用明确的 context.Update(entity),差异在于整个对象都将被标记为 Modified,并且每个属性都将被更新。
在这种情况下,您不需要跟踪,但缺点如上所述。
也许对您来说这不是问题,但它更加昂贵,我希望在保存时记录确切的更改,因此我需要正确的信息。

            // We always want tracking for auto-updates
            var entityToUpdate = unitOfWork.GetGenericRepository<Article, int>()
                .GetAllActive() // Uses EF tracking
                .Include(e => e.Barcodes.Where(e => e.Status == DatabaseEntityStatus.Active))
                .First(e => e.Id == request.Id);

            mapper.Map(request, entityToUpdate); // Maps it to entity with AutoMapper <3
            ModifyBarcodes(entityToUpdate, request);

            // Removed part of the code for space

            unitOfWork.Save();

在这里修改条形码部分。
我们希望以一种不会让EF跟踪出现问题的方式修改我们的集合。
自动映射将创建一个全新的集合实例,从而导致跟踪出现混乱,尽管我很确定它应该能够工作。
无论如何,由于我从FE发送了完整的列表,因此我们实际上确定了应该添加/更新/删除什么,并处理列表本身。
由于EF跟踪是开启状态,EF可以轻松处理它。

            var toUpdate = article.Barcodes
                .Where(e => articleDto.Barcodes.Select(b => b.Id).Contains(e.Id))
                .ToList();

            toUpdate.ForEach(e =>
            {
                var newValue = articleDto.Barcodes.FirstOrDefault(f => f.Id == e.Id);
                mapper.Map(newValue, e);
            });

            var toAdd = articleDto.Barcodes
                .Where(e => !article.Barcodes.Select(b => b.Id).Contains(e.Id))
                .Select(e => mapper.Map<Barcode>(e))
                .ToList();

            article.Barcodes.AddRange(toAdd);

            article.Barcodes
                .Where(e => !articleDto.Barcodes.Select(b => b.Id).Contains(e.Id))
                .ToList()
                .ForEach(e => article.Barcodes.Remove(e));


CreateMap<ArticleDto, Article>()
            .ForMember(e => e.DateCreated, opt => opt.Ignore())
            .ForMember(e => e.DateModified, opt => opt.Ignore())
            .ForMember(e => e.CreatedById, opt => opt.Ignore())
            .ForMember(e => e.LastModifiedById, opt => opt.Ignore())
            .ForMember(e => e.Status, opt => opt.Ignore())
            // When mapping collections, the reference itself is destroyed
            // hence f* up EF tracking and makes it think all previous is deleted
            // Better to leave it on manual and handle collecion manually
            .ForMember(e => e.Barcodes, opt => opt.Ignore())
            .ReverseMap()
            .ForMember(e => e.Barcodes, opt => opt.MapFrom(src => src.Barcodes.Where(e => e.Status == DatabaseEntityStatus.Active)));

1

这只是一个概念验证,Controler.UpdateModel 不会正常工作。

完整的类请看这里:

const string PK = "Id";
protected Models.Entities con;
protected System.Data.Entity.DbSet<T> model;

private void TestUpdate(object item)
{
    var props = item.GetType().GetProperties();
    foreach (var prop in props)
    {
        object value = prop.GetValue(item);
        if (prop.PropertyType.IsInterface && value != null)
        {
            foreach (var iItem in (System.Collections.IEnumerable)value)
            {
                TestUpdate(iItem);
            }
        }
    }

    int id = (int)item.GetType().GetProperty(PK).GetValue(item);
    if (id == 0)
    {
        con.Entry(item).State = System.Data.Entity.EntityState.Added;
    }
    else
    {
        con.Entry(item).State = System.Data.Entity.EntityState.Modified;
    }

}

0

这不是最优雅的方法,但它可以工作。干杯!

var entity = await context.Entities.FindAsync(id);

var newEntity = new AmazingEntity() {
  p1 = child1
  p2 = child2
  p3 = child3.child4 //... nested collections
};

if (entity != null) 
{
  db.Entities.Remove(entity);
}

db.Entities.Add(newEntity);

await db.SaveChangesAsync();

记得删除主键。

var child4 = Tools.CloneJson(deepNestedElement);
child4.id = 0;
child3.Add(child4);


public static class Tools
{
  public static JsonSerializerSettings jsonSettings = new JsonSerializerSettings {
    ObjectCreationHandling = ObjectCreationHandling.Replace,
    ReferenceLoopHandling = ReferenceLoopHandling.Ignore
  }; 

  public static string JSerialize<T>(T source) {       
    return JsonConvert.SerializeObject(source, Formatting.Indented, jsonSettings);
  }

  public static T JDeserialize<T>(string source) {       
    return JsonConvert.DeserializeObject<T>(source, jsonSettings);
  }

  public static T CloneJson<T>(this T source)
  { 
    return CloneJson<T, T>(source);
  }

  public static TOut CloneJson<TIn, TOut>(TIn source)
  { 
    if (Object.ReferenceEquals(source, null))      
      return default(TOut);      
    return JDeserialize<TOut>(JSerialize(source));
  }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接