Как обновить коллекцию в Many-Many, назначив новую коллекцию? - PullRequest
0 голосов
/ 25 мая 2018

В ядре 2.0 структуры сущностей у меня есть много-много отношений между Post и Category (класс привязки 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'} уже отслеживается.

Как эффективно перестроить новую коллекцию (или просто назначить новую коллекцию) без получения этого исключения?

ОБНОВЛЕНИЕ

Ответ в этой ссылке , кажется, связан с тем, что я хочу, но это хороший и эффективный метод?Есть ли какой-нибудь возможный лучший подход?

ОБНОВЛЕНИЕ 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;
}

1 Ответ

0 голосов
/ 25 мая 2018

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

Ответ по ссылке в порядке (пользовательский метод Except слишком тяжел для того, что он делает IMO), но он имеет некоторые ловушки - существующие ссылки должны быть получены заранее, используя eager / явныйзагрузка (хотя в случае 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()));
    }
}

Он использует один запрос к базе данных для извлечения исходящих ссылок из базы данных.Издержки - это несколько динамически построенных выражений и скомпилированных делегатов (чтобы максимально упростить вызывающий код) и один временный 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();

Обратите внимание, что этот метод позволяет изменить требование и принять IEnumerable<int> postCategoryIds:

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

или IEnumerable<Category> postCategories:

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

или аналогичные DTO / ViewModels.

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

Обновление: В случае, если вы получаете (потенциально) измененный 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 связывать связанные данные при извлечении основного объекта из базы данных.

...