In entity framework core 2.0, ho molte molte relazioni tra Post
e Category
(la classe di binding è PostCategory
).
Quando l'utente aggiorna un Post
, l'intero oggetto Post
(con la sua raccolta PostCategory
) viene inviato al server, e qui voglio riassegnare la nuova Collezione PostCategory
ricevuta (l'utente può cambiare questa Collezione in modo significativo aggiungendo nuove categorie e rimuovendo alcune categorie).
Codice semplificato che uso per aggiornare quella raccolta (assegno solo una raccolta completamente nuova):
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();
Questa nuova raccolta ha oggetti con lo stesso Id degli oggetti nella raccolta precedente (ad esempio l'utente ha rimosso alcune (ma non tutte) categorie) . A causa del, ottengo un'eccezione:
System.InvalidOperationException: l'istanza del tipo di entità "PostCategory" non può essere tracciata perché un'altra traccia con lo stesso valore chiave per {'CategoryId', 'PostId'} è già stata tracciata.
Come posso ricostruire la nuova collezione (o semplicemente assegnare una nuova collezione) in modo efficiente senza ottenere questa eccezione?
La risposta in questo collegamento sembra essere correlata a ciò che voglio, ma è un metodo valido ed efficace? C'è un possibile approccio migliore?
Ottengo il mio post (per modificare sovrascrivere i suoi valori) in questo modo:
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);
}
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.
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;
}
La sfida principale quando si lavora con entità di collegamento disconnesse è di rilevare e applicare i collegamenti aggiunti e cancellati. E EF Core (al momento della scrittura) fornisce poco o nessun aiuto per farlo.
La risposta dal link è ok (il metodo Custom Except
è troppo pesante per quello che fa IMO), ma ha alcune trappole: i collegamenti esistenti devono essere recuperati in anticipo usando il carico desideroso / esplicito (sebbene con EF Core 2.1 lazy carico che potrebbe non essere un problema), e i nuovi collegamenti dovrebbero avere solo le proprietà FK abitato - se contengono proprietà di navigazione di riferimento, EF Nucleo cercherà di creare nuove entità legate al momento della chiamata Add
/ AddRange
.
Qualche tempo fa ho risposto a una domanda simile, ma leggermente diversa: metodo generico per l'aggiornamento dei join EFCore . Ecco la versione più generalizzata e ottimizzata del metodo di estensione generico personalizzato dalla risposta:
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()));
}
}
Utilizza una singola query di database per recuperare i collegamenti in uscita dal database. Il sovraccarico è costituito da poche espressioni e delegati compilati dinamicamente (per mantenere il codice di chiamata il più semplice possibile) e un singolo HashSet
temporaneo per una rapida ricerca. L'effetto sulle prestazioni dell'espressione / delega deve essere trascurabile e può essere memorizzato nella cache, se necessario.
L'idea è di passare solo una singola chiave esistente per una delle entità collegate e un elenco di chiavi in uscita per l'altra entità collegata. Quindi, a seconda di quale collegamento di entità collegata si sta aggiornando, verrà chiamato in modo diverso.
Nel tuo esempio, supponendo che tu stia ricevendo IEnumerable<PostCategory> postCategories
, il processo sarebbe simile a questo:
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();
Si noti che questo metodo consente di modificare il requisito e accettare IEnumerable<int> postCategoryIds
:
dbContext.Set<PostCategory>().UpdateLinks(pc =>
pc.PostId, post.Id, pc => pc.CategoryId, postCategoryIds);
o IEnumerable<Category> postCategories
:
dbContext.Set<PostCategory>().UpdateLinks(pc =>
pc.PostId, post.Id, pc => pc.CategoryId, postCategories.Select(c => c.Id));
o DTO / ViewModels simili.
I post di categoria possono essere aggiornati in modo simile, con i corrispondenti selettori scambiati.
Aggiornamento: nel caso in cui tu riceva un'istanza di Post post
(potenzialmente) modificata, l'intera procedura di aggiornamento sarà come questa:
// 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();
Tieni presente che EF Core utilizza una query di database separata per le raccolte relative al caricamento ansioso. Poiché il metodo helper fa lo stesso, non è necessario Include
i dati relativi al collegamento quando si recupera l'entità principale dal database.