如何通过分配新集合来更新多对多集合?

Moh*_*din 7 .net c# entity-framework entity-framework-core .net-core

在实体框架核心 2.0 中,我在Post和之间有很多关系Category(绑定类是PostCategory)。

当用户更新 a 时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();
Run Code Online (Sandbox Code Playgroud)

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

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

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

更新

此链接中的答案似乎与我想要的有关,但它是一种很好且有效的方法吗?有没有可能更好的方法?

更新 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);
}
Run Code Online (Sandbox Code Playgroud)

更新 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.
Run Code Online (Sandbox Code Playgroud)

更新 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;
}
Run Code Online (Sandbox Code Playgroud)

Iva*_*oev 9

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

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

不久前,我回答了类似但略有不同的问题 -更新 EFCore 连接的通用方法。这是来自答案的自定义通用扩展方法的更通用和优化的版本:

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()));
    }
}
Run Code Online (Sandbox Code Playgroud)

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

这个想法是只传递一个链接实体的单个现有密钥和另一个链接实体的现有密钥列表。因此,根据您正在更新的链接实体链接,它将以不同的方式调用。

在您的示例中,假设您正在接收IEnumerable<PostCategory> 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();
Run Code Online (Sandbox Code Playgroud)

请注意,此方法允许您更改要求并接受IEnumerable<int> postCategoryIds

dbContext.Set<PostCategory>().UpdateLinks(pc => 
    pc.PostId, post.Id, pc => pc.CategoryId, postCategoryIds);
Run Code Online (Sandbox Code Playgroud)

IEnumerable<Category> postCategories

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

或类似的 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();
Run Code Online (Sandbox Code Playgroud)

请注意,EF Core 使用单独的数据库查询来预先加载相关集合。由于helper 方法也是这样做的,所以Include在从数据库中检索主实体时不需要链接相关数据。