【问题标题】:How to update a Collection in Many-Many by assigning a new Collection?如何通过分配新集合来更新多对多集合?
【发布时间】:2018-05-28 04:05:50
【问题描述】:

在实体框架核心2.0中,PostCategory之间存在多对多关系(绑定类为PostCategory)。

当用户更新Post 时,整个Post 对象(及其PostCategory 集合)被发送到服务器,在这里我想重新分配新收到的集合PostCategory(用户可以通过添加新类别和删除一些类别来显着更改此集合)。

我用来更新该集合的简化代码(我只是分配了全新的集合):

var post = await dbContext.Posts
    .Include(p => p.PostCategories)
    .ThenInclude(pc => pc.Category)
    .SingleOrDefaultAsync(someId);

post.PostCategories = ... Some new collection...; // <<<
dbContext.Posts.Update(post);
await dbContext.SaveChangesAsync();

这个新集合中的对象与前一个集合中的对象 ID 相同(例如,用户删除了一些(但不是全部)类别)。因为,我得到了一个例外:

System.InvalidOperationException:无法跟踪实体类型“PostCategory”的实例,因为已经在跟踪具有相同键值 {'CategoryId', 'PostId'} 的另一个实例。

如何有效地重建新集合(或简单地分配新集合)而不会出现此异常?

更新

The answer in this link 似乎和我想要的有关,但它是一种好而有效的方法吗?有没有更好的办法?

更新 2

我的帖子(编辑覆盖其值)是这样的:

public async Task<Post> GetPostAsync(Guid postId)
{
    return await dbContext.Posts
        .Include(p => p.Writer)
            .ThenInclude(u => u.Profile)
        .Include(p => p.Comments)
        .Include(p => p.PostCategories)
            .ThenInclude(pc => pc.Category)
        .Include(p => p.PostPackages)
            .ThenInclude(pp => pp.Package)
        //.AsNoTracking()
        .SingleOrDefaultAsync(p => p.Id == postId);
}

更新 3(我的控制器中的代码,它试图更新帖子):

var writerId = User.GetUserId();
var categories = await postService.GetOrCreateCategoriesAsync(
    vm.CategoryViewModels.Select(cvm => cvm.Name), writerId);

var post = await postService.GetPostAsync(vm.PostId);
post.Title = vm.PostTitle;
post.Content = vm.ContentText;

post.PostCategories = categories?.Select(c => new PostCategory { CategoryId = c.Id, PostId = post.Id }).ToArray();

await postService.UpdatePostAsync(post); // Check the implementation in Update4.

更新 4:

public async Task<Post> UpdatePostAsync(Post post)
{
    // Find (load from the database) the existing post
    var existingPost = await dbContext.Posts
        .SingleOrDefaultAsync(p => p.Id == post.Id);

    // Apply primitive property modifications
    dbContext.Entry(existingPost).CurrentValues.SetValues(post);

    // Apply many-to-many link modifications
    dbContext.Set<PostCategory>().UpdateLinks(
        pc => pc.PostId, post.Id,
        pc => pc.CategoryId,
        post.PostCategories.Select(pc => pc.CategoryId)
    );

    // Apply all changes to the db
    await dbContext.SaveChangesAsync();

    return existingPost;
}

【问题讨论】:

  • @DanielB,谢谢。我已经在问题的更新中提到了这一点。
  • 请注意您没有包含导航属性。我不太确定 changetracking 将如何处理导航属性的设置(我现在无法尝试),但我怀疑它会起作用。您可以尝试设置每个对象的状态(在上一个导航属性和新导航属性中),但这会很乏味且容易出错,所以我怀疑给定的答案是最好的方法。
  • @DevilSuichiro,谢谢你,你是对的,我只是在这里写代码。我在问题中添加了include

标签: c# .net entity-framework .net-core entity-framework-core


【解决方案1】:

使用断开链接实体时的主要挑战是检测和应用添加和删除的链接。 EF Core(截至撰写本文时)几乎没有提供帮助。

链接的答案是好的(自定义 Except 方法对于 IMO 的作用来说太重了),但它有一些陷阱 - 必须使用急切/显式加载提前检索现有链接(尽管EF Core 2.1 延迟加载可能不是问题),并且新链接应该只填充 FK 属性 - 如果它们包含引用导航属性,EF Core 将在调用 Add / AddRange 时尝试创建新的链接实体.

不久前,我回答了类似但略有不同的问题 - Generic method for updating EFCore joins。这是答案中自定义泛型扩展方法的更通用和优化的版本:

public static class EFCoreExtensions
{
    public static void UpdateLinks<TLink, TFromId, TToId>(this DbSet<TLink> dbSet,
        Expression<Func<TLink, TFromId>> fromIdProperty, TFromId fromId,
        Expression<Func<TLink, TToId>> toIdProperty, IEnumerable<TToId> toIds)
        where TLink : class, new()
    {
        // link => link.FromId == fromId
        Expression<Func<TFromId>> fromIdVar = () => fromId;
        var filter = Expression.Lambda<Func<TLink, bool>>(
            Expression.Equal(fromIdProperty.Body, fromIdVar.Body),
            fromIdProperty.Parameters);
        var existingLinks = dbSet.AsTracking().Where(filter);

        var toIdSet = new HashSet<TToId>(toIds);
        if (toIdSet.Count == 0)
        {
            //The new set is empty - delete all existing links 
            dbSet.RemoveRange(existingLinks);
            return;
        }

        // Delete the existing links which do not exist in the new set
        var toIdSelector = toIdProperty.Compile();
        foreach (var existingLink in existingLinks)
        {
            if (!toIdSet.Remove(toIdSelector(existingLink)))
                dbSet.Remove(existingLink);
        }

        // Create new links for the remaining items in the new set
        if (toIdSet.Count == 0) return;
        // toId => new TLink { FromId = fromId, ToId = toId }
        var toIdParam = Expression.Parameter(typeof(TToId), "toId");
        var createLink = Expression.Lambda<Func<TToId, TLink>>(
            Expression.MemberInit(
                Expression.New(typeof(TLink)),
                Expression.Bind(((MemberExpression)fromIdProperty.Body).Member, fromIdVar.Body),
                Expression.Bind(((MemberExpression)toIdProperty.Body).Member, toIdParam)),
            toIdParam);
        dbSet.AddRange(toIdSet.Select(createLink.Compile()));
    }
}

它使用单个数据库查询从数据库中检索现有链接。开销是很少的动态构建的表达式和编译的委托(为了使调用代码尽可能简单)和一个临时的HashSet 用于快速查找。表达式/委托构建的性能影响应该可以忽略不计,如果需要可以缓存。

这个想法是只为一个链接实体传递一个现有密钥,并为另一个链接实体传递现有密钥列表。因此,根据您要更新的链接实体链接的不同,它的调用方式会有所不同。

在您的示例中,假设您收到IEnumerable&lt;PostCategory&gt; postCategories,该过程将是这样的:

var post = await dbContext.Posts
    .SingleOrDefaultAsync(someId);

dbContext.Set<PostCategory>().UpdateLinks(pc => 
    pc.PostId, post.Id, pc => pc.CategoryId, postCategories.Select(pc => pc.CategoryId));

await dbContext.SaveChangesAsync();

请注意,此方法允许您更改要求并接受IEnumerable&lt;int&gt; postCategoryIds

dbContext.Set<PostCategory>().UpdateLinks(pc => 
    pc.PostId, post.Id, pc => pc.CategoryId, postCategoryIds);

IEnumerable&lt;Category&gt; postCategories:

dbContext.Set<PostCategory>().UpdateLinks(pc => 
    pc.PostId, post.Id, pc => pc.CategoryId, postCategories.Select(c => c.Id));

或类似的 DTO / ViewModel。

可以以类似的方式更新类别帖子,并交换相应的选择器。

更新:如果您收到(可能)修改的Post post实体实例,整个更新过程如下:

// Find (load from the database) the existing post
var existingPost = await dbContext.Posts
    .SingleOrDefaultAsync(p => p.Id == post.Id);

if (existingPost == null)
{
    // Handle the invalid call
    return;
}

// Apply primitive property modifications
dbContext.Entry(existingPost).CurrentValues.SetValues(post);

// Apply many-to-many link modifications
dbContext.Set<PostCategory>().UpdateLinks(pc => pc.PostId, post.Id, 
    pc => pc.CategoryId, post.PostCategories.Select(pc => pc.CategoryId));

// Apply all changes to the db
await dbContext.SaveChangesAsync();

请注意,EF Core 使用单独的数据库查询来预先加载相关的集合。由于辅助方法的作用相同,因此在从数据库中检索主实体时,无需Include 链接相关数据。

【讨论】:

  • 这很奇怪,因为原始值不包括导航属性。所以Post 原始属性与异常中显示的PostCategory 没有任何共同之处。看起来您正在执行不同的代码 - 比如UpdateAdd?看,在控制器调用中,源通常不是实体,即使它是,也不应该是附加到上下文的实体。上下文通常是短暂的(仅用于调用),目标是更新数据库。试图保持传递实例的身份既困难又低效。
  • 这是完全不同的用法(我讨厌这些存储库隐藏正在发生的事情)。您首先检索一个跟踪的Post 实体(对应于我的originalPost 变量)并热切加载了post.PostCategories。然后post.PostCategories = categories?.Select(c =&gt; new PostCategory { CategoryId = c.Id, PostId = post.Id }).ToArray(); 行破坏了原始集合的跟踪信息。使用这种用法,可能最好使用链接答案中的方法,但要公开特殊的存储库方法,您可以在其中传递旧集合和新集合。
  • 我的意思是你的 Update 3。当您收到带有postService.GetPostAsync(vm.PostId); 的帖子并从视图模型设置原始属性时,您只需调用存储库的特殊方法(可以访问数据库上下文)传递currentItemsnewItems TryUpdateManyToMany 方法来自 Update 1 中链接的答案。最后,您必须以某种方式致电dbContext.SaveChangesAsync()。在这种情况下根本不需要UpdatePostAsync 方法,因为post 对象被跟踪。
  • 我不知道伙计。我只能说直接使用 db 上下文在处理这种情况和类似情况时没有问题,而我见过的所有存储库模式实现似乎只处理具有独立实体且没有关系的简单场景,并且在更复杂的场景中失败。
  • 谢谢你,Ivan,你的 cmets 帮助我理解了很多我以前没有正确理解的东西。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-02-03
  • 2018-07-29
  • 2021-08-16
  • 1970-01-01
  • 2017-09-10
相关资源
最近更新 更多