Search code examples
c#entity-frameworkasp.net-coreentity-framework-core

EF Core disconnected update of collection navigation properties via full replacement


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?


Solution

  • Update:

    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);
            }
        }
    }