Reputation: 2451
Using EF Core 5.0. I have a SPA page that loads a Group
entity with its collection of Employee
entities from the API:
var groupToUpdate = await context.Groups
.Include(g => g.Employees)
.FirstOrDefaultAsync(...);
//Used for UI, list of additional employees for selective adding
var employeeList = await context.Employees.
.Where(...)
.ToListAsync();
The user then modifies the groupToUpdate
entity via the Javascript UI, including some non-navigation properties such as name/notes.
On the same screen, the user adds some employees to the group, removes some employees from the group, and leave some existing employees in the group intact. All employees are existing entities in the DB with existing primary keys. All changes done thus far are just to the disconnected entity in memory.
When the user clicks save, the groupToUpdate
entity is sent to my backend code. Note that we did not keep track of which employees are added/removed/left alone, we just want to let this groupToUpdate
completely override the old entity, notably replacing the old collection of Employees
with the new one.
To achieve this, the backend code first loads the group again from the database to start tracking it in context. Then I attempt to update the entity, including replacing the old collection with the new one:
public async Task UpdateGroupAsync(Group groupToUpdate)
{
var groupFromDb = await context.Groups
.Include(g => g.Employees)
.FirstOrDefaultAsync(...);
// Update non-navigation properties such as groupFromDb.Note = groupToUpdate.Note...
groupFromDb.Employees = groupToUpdate.Employees;
await context.SaveChangesAsync();
}
Now if changes to the Employees
collection is total replacement (all old ones removed, all new ones added) , this method succeeds. But whenever there are some existing Employees
that are left alone, EF core throws the exception:
The instance of entity type 'Employee' cannot be tracked because another instance with the key value ... is already being tracked
So it seems EF Core attempts to track both the Employee
entities fresh loaded from the DB with groupFromDb
and the ones from groupToUpdate
, even though the latter is merely passed in as a parameter from a disconnected state.
My question is how to handle this kind of update with the least amount of complications? Is it necessary to keep track of the added/removed entities manually and adding/removing them instead of trying to replace the entire collection?
Upvotes: 4
Views: 2192
Reputation: 27282
Added another more flexible implementation CollectionHelpers
You have to instruct ChangeTracker which operations are needed for updating navigation collection. Just replacing collection is not correct way.
This is extension which helps to do that automatically:
context.MergeCollections(groupFromDb.Employees, groupToUpdate.Employees, x => x.Id);
Implementation:
public static void MergeCollections<T, TKey>(this DbContext context, ICollection<T> currentItems, ICollection<T> newItems, Func<T, TKey> keyFunc)
where T : class
{
List<T> toRemove = null;
foreach (var item in currentItems)
{
var currentKey = keyFunc(item);
var found = newItems.FirstOrDefault(x => currentKey.Equals(keyFunc(x)));
if (found == null)
{
toRemove ??= new List<T>();
toRemove.Add(item);
}
else
{
if (!ReferenceEquals(found, item))
context.Entry(item).CurrentValues.SetValues(found);
}
}
if (toRemove != null)
{
foreach (var item in toRemove)
{
currentItems.Remove(item);
// If the item should be deleted from Db: context.Set<T>().Remove(item);
}
}
foreach (var newItem in newItems)
{
var newKey = keyFunc(newItem);
var found = currentItems.FirstOrDefault(x => newKey.Equals(keyFunc(x)));
if (found == null)
{
currentItems.Add(newItem);
}
}
}
Upvotes: 6
Reputation: 14012
I've re-used idea from this answer to have an extension method for updating the collection
context.MergeCollections(groupFromDb.Employees, groupToUpdate.Employees, x => x.Id);
and added more elegant approach inside the extension method
public static void MergeCollections<T, TKey>(this DbContext context, ICollection<T> currentItems, ICollection<T> newItems, Func<T, TKey> keyFunc)
where T : class
{
var itemsToRemove = currentItems.ExceptBy(newItems.Select(keyFunc), keyFunc);
var itemsToAdd = newItems.ExceptBy(currentItems.Select(keyFunc), keyFunc);
foreach (var item in newItems.Except(itemsToAdd))
context.Update(item);
if (itemsToRemove.Any())
context.RemoveRange(itemsToRemove);
if (itemsToAdd.Any())
context.AddRange(itemsToAdd);
}
Upvotes: 0