上下文。SaveChangesAsync() 未按预期工作

context.SaveChangesAsync() doesn't work as expected

提问人:Flu 提问时间:11/17/2023 最后编辑:ℍ ℍFlu 更新时间:11/18/2023 访问量:67

问:

我在使用 EF 更新数据库时遇到问题。 在我的应用程序中,我有 Orders 和 OrderPositions。OrderPosition 可以是 FoodPosition 或 ReservationPosition。因此,我的 Order 类中有两个列表,一个用于 FoodPositions,一个用于 ReservationPositions。

我的问题是使用FoodPositions和ReservationPositions更新订单。 当我想在以下行中保存对数据库的更改时:

await context.SaveChangesAsync();

此行是 UpdateAsync 方法的一部分:

private async Task UpdateAsync(Order order)
{
    Order contextOrder = context.Set<Order>()
        .Include(o => o.FoodPositions)
        .Include(o => o.ReservationPositions)
        .Single(e => e.Id == order.Id);

    contextOrder.FoodPositions = order.FoodPositions;
    contextOrder.ReservationPositions = order.ReservationPositions;
    contextOrder.Status = order.Status;
    contextOrder.Cost = order.Cost;
    contextOrder.CostCenter = order.CostCenter;

    context.Update(contextOrder);
    await context.SaveChangesAsync();
}

它不会完成该行,而是返回到调用方法的位置。

这是我的数据库上下文中用于绑定外键的代码:

modelBuilder.Entity<FoodPosition>()
    .HasOne<Order>()
    .WithMany(e => e.FoodPositions);

modelBuilder.Entity<ReservationPosition>()
    .HasOne<Order>()
    .WithMany(e => e.ReservationPositions);

当我尝试在没有这两行的情况下更新订单时:

contextOrder.FoodPositions = order.FoodPositions;
contextOrder.ReservationPositions = order.ReservationPositions;

它工作得很好,并且它通过 saveChangesAsync 行。

我没有收到任何错误,它只是没有按照我想要的方式工作。因为它不会按预期保存更新的订单以及所有更新的属性值和新的 OrderPositions。

也许用新的 OrderPositions 替换 OrderPositions 会有一些冲突,它不知道如何在数据库中处理它。

C# 数据库 entity-framework-core

评论

0赞 Marcel Callo 11/17/2023
如果你把你的代码放在一个try/catch集团中,你有什么例外?
0赞 JonasH 11/17/2023
“不完成线”是什么意思?每当使用异步方法时,都应确保等待每个任务,从所有异步方法返回一个任务。例如,如果由于在按钮事件处理程序中而无法返回任务,则需要确保捕获并处理来自 .await
0赞 Flu 11/17/2023
这是我得到的异常:System.InvalidOperationException:无法跟踪实体类型“FoodPosition”的实例,因为已跟踪具有 {'Id'} 的相同键值的另一个实例。附加现有实体时,请确保仅附加一个具有给定键值的实体实例。请考虑使用“DbContextOptionsBuilder.EnableSensitiveDataLogging”来查看冲突的键值。 @MarcelCallo
0赞 Panagiotis Kanavos 11/17/2023
@Flu 该错误是由误用 EF Core 引起的。SaveChanges 工作正常。 但从根本上说是坏的,并表明您正在使用 CRUD 类而不是 DbContext。这是一个非常糟糕的反模式,DbContext 是一个高级工作单元,而不是一个低级 CRUD 类。 在状态中跟踪。调用它时,会尝试将它和所有相关对象附加到 or 状态。UpdateAsynccontextOrderModifiedcontext.UpdateAddedModified
0赞 Panagiotis Kanavos 11/17/2023
参数值从何而来?如果它是从数据库加载的,则根本不需要。如果它来自 ASP.NET Core 请求,则足以将其跟踪为修改。 本质上是一个UPSERT。如果对象的 PK 具有默认值,则它将对象及其关系附加到状态中,如果它们具有实际值,则附加状态。鉴于返回的查询 ,显然具有非默认值orderUpdateAsynccontect.Update(order)UpdateAddedModifiedcontextOrderID

答:

0赞 JonasH 11/17/2023 #1

从有关更改跟踪的文档

实体实例在以下情况下将被跟踪:

  • 从对数据库执行的查询返回
  • 通过 Add、Attach、Update 或类似方法显式附加到 DbContext
  • 检测为连接到现有跟踪实体的新实体

因此,当您的初始查询返回 contextOrder 时,会被跟踪,但不会被跟踪。这两个对象可能共享相同的 Id,因此当您运行时,它有两个具有相同 ID 的不同跟踪对象,即异常所说的内容。这与异步与否无关。contextOrder.FoodPositionsorder.FoodPositions.Update

您可以尝试添加 .AsNoTracking() 来确保在显式执行此操作之前不会跟踪实体,但请注意,当您这样做时,将在对象图上进行搜索,并且所有找到的具有现有键的实体将被跟踪为“已修改”,而没有键的实体(即 0)将被跟踪为“已添加”。.Update(..)

还可以显式设置单个实体的跟踪状态。contextOrder.Entry(myEntity).State = EntityState.Modified

您可能还需要考虑上下文对象的生存期。我最熟悉的方法是确保上下文是短暂的。也就是说,你创建你的上下文,将它用于你需要做的任何事情,并处理它。如果你的上下文是长期存在的,你需要更密切地关注哪些实体被跟踪,哪些实体没有被跟踪,以及处于什么状态。

0赞 Marcel Callo 11/17/2023 #2

从数据库中获取实体的 ID 并对实体进行更改后,只需调用 context。SaveChangesAsync() 方法将更改传播到数据库。

private async Task UpdateAsync(Order order)
{
    // ...
    contextOrder.Cost = order.Cost;
    contextOrder.CostCenter = order.CostCenter;

    await context.SaveChangesAsync();
}
0赞 Steve Py 11/18/2023 #3

更新分离的聚合实体需要特别小心。在 EF 中,引用就是一切,如果 DbContext 未跟踪您提供的引用,则会遇到异常或重复数据情况。如果它已经在跟踪同一行的另一个引用,则再次出现异常。

首先,在加载跟踪引用 (existingOrder) 时,您不使用 . 用于附加一个分离的实体,并告诉 EF 将整个事物视为已修改。这不适用于引用的其他实体,因此需要单独完成这些操作。我不建议使用,因为它效率低下,即使实际上什么都没有改变,也会将所有内容标记为已更改,并且您“信任”传入的数据,因此在许多情况下(例如 Web 应用程序)这会使您的系统受到篡改。相反,加载跟踪的实体,复制允许的值,然后调用 .UpdateUpdateUpdateSaveChanges()

下一个问题是处理导航属性、FoodPositions 和 ReservationPositions。对于导航属性,您需要考虑这些属性是表示关联还是更“拥有”的类型关系。例如,这些是多对多关系,其中订单与一个或多个现有 Food 或 FoodPositions 相关联,还是 FoodPositions 创建并被视为订单的一对多子级?相同的基本规则适用于这两种情况,但它们的处理方式略有不同,因为在保存 Order 之前,DbContext 需要跟踪任何关联的内容。

我建议用于更新关联的典型代码结构如下所示:(将代码应用于 FoodPositions,您可以将其应用于 ReservationPositions。这假设拥有更多的一对多关系)

private async Task UpdateAsync(Order order)
{
    Order contextOrder = await context.Set<Order>()
        .Include(o => o.FoodPositions)
        .Include(o => o.ReservationPositions)
        .SingleAsync(e => e.Id == order.Id);

    var existingFoodPositionIds = contextOrder.FoodPositions
        .Select(x => x.Id)
        .ToList();
    var updatedFoodPositionIds = order.FoodPositions
        .Select(x => x.Id)
        .ToList();
    var foodPositionIdsToAdd = updatedFoodPositionIds
        .Except(existingFoodPositionIds);
    var foodPositionIdsToRemove = existingFoodPositionIds
        .Except(updatedFoodPositionIds);

    if (foodPositionIdsToRemove.Any())
    {
        var foodPositionsToRemove = contextOrder.FoodPositions
            .Where(x => foodPositionIdsToRemove.Contains(x.Id))
            .ToList();
        foreach(var foodPosition in foodPositionsToRemove)
            contextOrder.FoodPositions.Remove(foodPosition);
    }
    if (foodPositionIdsToAdd.Any())
    {
        var foodPositionsToAdd = order.FoodPositions
            .Where(x => foodPositionIdsToAdd.Contains(x.Id))
            .ToList();
        foreach(var foodPosition in foodPositionsToAdd)
            contextOrder.FoodPositions.Add(foodPosition);
    }

    contextOrder.Status = order.Status;
    contextOrder.Cost = order.Cost;
    contextOrder.CostCenter = order.CostCenter;

    await context.SaveChangesAsync();
}

从本质上讲,我们比较列表以查找需要添加或删除的项目,然后继续将它们添加或删除到跟踪的订单中。这可能涉及相当多的代码,因此我可能会将其分解为一个简单的方法来调用(即 updateOrderFoodPositions(order, contextOrder)),而不是增加 UpdateOrder 以涵盖所有内容。

0赞 Svyatoslav Danyliv 11/18/2023 #4

您必须自己关心导航集合,它们在分离时不会自动更新。

我创建了通用方法,可以帮助您简化这种情况。使用此方法可以更新集合属性。MergeCollections

private async Task UpdateAsync(Order order)
{
    Order contextOrder = context.Set<Order>()
        .Include(o => o.FoodPositions)
        .Include(o => o.ReservationPositions)
        .Single(e => e.Id == order.Id);

    CollectionHelpers.MergeCollections(context, contextOrder.FoodPositions, order.FoodPositions, e => e.Id);
    CollectionHelpers.MergeCollections(context, contextOrder.ReservationPositions, order.ReservationPositions, e => e.Id);

    contextOrder.Status = order.Status;
    contextOrder.Cost = order.Cost;
    contextOrder.CostCenter = order.CostCenter;

    await context.SaveChangesAsync();
}

和实施:

public class CollectionHelpers
{
    public static void MergeCollections<TItem, TKey>(
        DbContext context,
        ICollection<TItem> destination,
        ICollection<TItem> source,
        Func<TItem, TKey> keyFunc) 
        where TItem: notnull
        where TKey : notnull
    {
        MergeCollections(destination, source, keyFunc, keyFunc, e => e,
            (d, s) => context.Entry(d).CurrentValues.SetValues(s),
            d => context.Remove(d)
        );
    }

    public static void MergeCollections<TItem, TKey>(
        ICollection<TItem> destination,
        ICollection<TItem> source,
        Func<TItem, TKey> keyFunc,
        Action<TItem, TItem> onUpdate,
        Action<TItem> onDelete) 
        where TKey : notnull
    {
        MergeCollections(destination, source, keyFunc, keyFunc, e => e, onUpdate, onDelete);
    }

    public static void MergeCollections<TDestination, TSource, TDestinationKey>(
        ICollection<TDestination> destination,
        ICollection<TSource> source,
        Func<TDestination, TDestinationKey> destinationKeyFunc,
        Func<TSource, TDestinationKey> sourceKeyFunc,
        Func<TSource, TDestination> insertFactory,
        Action<TDestination, TSource> onUpdate,
        Action<TDestination> onDelete) 
        where TDestinationKey : notnull
    {
        // shortcuts
        if (source.Count == 0)
        {
            while (destination.Count > 0)
            {
                var first = destination.First();
                destination.Remove(first);
                onDelete(first);
            }   
            return;
        }

        if (destination.Count == 0)
        {
            foreach(var s in source)
                destination.Add(insertFactory(s));
            return;
        }

        var sourceDic = source.ToDictionary(sourceKeyFunc);

        foreach (var d in destination.ToList())
        {
            var key = destinationKeyFunc(d);
            if (sourceDic.TryGetValue(key, out var foundSource))
            {
                onUpdate(d, foundSource);
                sourceDic.Remove(key);
            }
            else
            {
                destination.Remove(d);
                onDelete(d);
            }
        }

        if (sourceDic.Count > 0)
        {
            foreach(var s in sourceDic.Values)
                destination.Add(insertFactory(s));
        }
    }
}