Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Update parent and child collections on generic repository with EF Core

Say I have a Sale class:

public class Sale : BaseEntity //BaseEntity only has an Id  
{        
    public ICollection<Item> Items { get; set; }
}

And an Item class:

public class Item : BaseEntity //BaseEntity only has an Id  
{
    public int SaleId { get; set; }
    public Sale Sale { get; set; }
}

And a Generic Repository (update method):

    public async Task<int> UpdateAsync<T>(T entity, params Expression<Func<T, object>>[] navigations) where T : BaseEntity
    {
        var dbEntity = _dbContext.Set<T>().Find(entity.Id);

        var dbEntry = _dbContext.Entry(dbEntity);

        dbEntry.CurrentValues.SetValues(entity);            

        foreach (var property in navigations)
        {
            var propertyName = property.GetPropertyAccess().Name;

            await dbEntry.Collection(propertyName).LoadAsync();

            List<BaseEntity> dbChilds = dbEntry.Collection(propertyName).CurrentValue.Cast<BaseEntity>().ToList();

            foreach (BaseEntity child in dbChilds)
            {
                if (child.Id == 0)
                {
                    _dbContext.Entry(child).State = EntityState.Added;
                }
                else
                {
                    _dbContext.Entry(child).State = EntityState.Modified;
                }
            }
        }

        return await _dbContext.SaveChangesAsync();
    }

I'm having difficulties to update the Item collection on the Sale class. With this code I managed to add or modify an Item. But, when I delete some item on the UI layer, nothing gets deleted.

Does EF Core have something to deal with this situation, while using a generic repository patter?

UPDATE

Seems to be that Items tracking is lost. Here is my generic retrieve method with includes.

    public async Task<T> GetByIdAsync<T>(int id, params Expression<Func<T, object>>[] includes) where T : BaseEntity
    {
        var query = _dbContext.Set<T>().AsQueryable();

        if (includes != null)
        {
            query = includes.Aggregate(query,
              (current, include) => current.Include(include));
        }

        return await query.SingleOrDefaultAsync(e => e.Id == id);
    }
like image 713
Gonzalo Lorieto Avatar asked Mar 10 '19 14:03

Gonzalo Lorieto


People also ask

Should I use EF core repository pattern?

Developers building applications with ASP.Net Core and Entity Framework Core should not use UoW and Repository pattern anymore. EF Core supports unit testing and mock contexts.


1 Answers

Apparently the question is for applying modifications of disconnected entity (otherwise you won't need to do anything else than calling SaveChanges) containing collection navigation properties which need to reflect the added/removed/update items from the passed object.

EF Core does not provide such out of the box capability. It supports simple upsert (insert or update) through Update method for entities with auto-generated keys, but it doesn't detect and delete the removed items.

So you need to do that detection yourself. Loading the existing items is a step in the right direction. The problem with your code is that it doesn't account the new items, but instead is doing some useless state manipulation of the existing items retrieved from the database.

Following is the correct implementation of the same idea. It uses some EF Core internals (IClrCollectionAccessor returned by the GetCollectionAccessor() method - both require using Microsoft.EntityFrameworkCore.Metadata.Internal;) to manipulate the collection, but your code already is using the internal GetPropertyAccess() method, so I guess that shouldn't be a problem - in case something is changed in some future EF Core version, the code should be updated accordingly. The collection accessor is needed because while IEnumerable<BaseEntity> can be used for generically accessing the collections due to covariance, the same cannot be said about ICollection<BaseEntity> because it's invariant, and we need a way to access Add / Remove methods. The internal accessor provides that capability as well as a way to generically retrieve the property value from the passed entity.

Update: Starting from EF Core 3.0, GetCollectionAccessor and IClrCollectionAccessor are part of the public API.

Here is the code:

public async Task<int> UpdateAsync<T>(T entity, params Expression<Func<T, object>>[] navigations) where T : BaseEntity
{
    var dbEntity = await _dbContext.FindAsync<T>(entity.Id);

    var dbEntry = _dbContext.Entry(dbEntity);
    dbEntry.CurrentValues.SetValues(entity);

    foreach (var property in navigations)
    {
        var propertyName = property.GetPropertyAccess().Name;
        var dbItemsEntry = dbEntry.Collection(propertyName);
        var accessor = dbItemsEntry.Metadata.GetCollectionAccessor();

        await dbItemsEntry.LoadAsync();
        var dbItemsMap = ((IEnumerable<BaseEntity>)dbItemsEntry.CurrentValue)
            .ToDictionary(e => e.Id);

        var items = (IEnumerable<BaseEntity>)accessor.GetOrCreate(entity);

        foreach (var item in items)
        {
            if (!dbItemsMap.TryGetValue(item.Id, out var oldItem))
                accessor.Add(dbEntity, item);
            else
            {
                _dbContext.Entry(oldItem).CurrentValues.SetValues(item);
                dbItemsMap.Remove(item.Id);
            }
        }

        foreach (var oldItem in dbItemsMap.Values)
            accessor.Remove(dbEntity, oldItem);
    }

    return await _dbContext.SaveChangesAsync();
}

The algorithm is pretty standard. After loading the collection from the database, we create a dictionary containing the existing items keyed by Id (for fast lookup). Then we do a single pass over the new items. We use the dictionary to find the corresponding existing item. If no match is found, the item is considered new and is simply added to the target (tracked) collection. Otherwise the found item is updated from the source, and removed from the dictionary. This way, after finishing the loop, the dictionary contains the items that needs to be deleted, so all we need is remove them from the target (tracked) collection.

And that's all. The rest of the work will be done by the EF Core change tracker - the added items to the target collection will be marked as Added, the updated - either Unchanged or Modified, and the removed items, depending of the delete cascade behavior will be either be marked for deletion or update (disassociate from parent). If you want to force deletion, simply replace

accessor.Remove(dbEntity, oldItem);

with

_dbContext.Remove(oldItem);
like image 139
Ivan Stoev Avatar answered Nov 15 '22 17:11

Ivan Stoev