Lajith
Lajith

Reputation: 1867

Update parent and child collections on TEntity generic repository

My Base repository Class

public class Repository<TEntity, TId> : IRepository<TEntity, TId> where TEntity : class, IEntity<TId>
{       
    protected readonly CBSContext _context;
    private DbSet<TEntity> _entities;
  
    public Repository(CBSContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _entities = _context.Set<TEntity>();
    }
    
    public async Task UpdateAsync(TEntity entity)
    {
        await Task.Run(() => _context.Entry(entity).State = EntityState.Modified);
       
    }

    //Update child enitity code added below 
}

And my Entity Interface

public interface IEntity<TId> 
{
    TId Id { get; set; }
}

public class Customer : IEntity<int>
{
    public int Id { get; set; } 
    public string CustomerNo { get; set; }
    public ICollection<Address> Addresses { get; set; } = new List<Address>();
}

I need to Add/update/delete child entities in disconnected scenarious.

AM referencing this answer Add/update/delete child entities

And that anser is based on custom BaseEnity but i am using IEnity..

Works done:

i have replace baseentity with Tentity. but Showing error

Below is new Code for save child elements

public async Task UpdateAsync(TEntity entity, params Expression<Func<TEntity, object>>[] navigations)
{
    var dbEntity = await _context.FindAsync<TEntity>(entity.Id);

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

    foreach (Expression<Func<TEntity, object>> property in navigations)
    {
        var propertyName = property.GetPropertyAccess().Name;
        var dbItemsEntry = dbEntry.Collection(propertyName);
        var accessor = dbItemsEntry.Metadata.GetCollectionAccessor();

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

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

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

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

    await Task.Run(() => _context.SaveChangesAsync());
}

Below Showing Error:

enter image description here

Is There any alternate approach..am new to .net core ..Please suggest if any alternate approach.

Upvotes: 4

Views: 1342

Answers (1)

Ivan Stoev
Ivan Stoev

Reputation: 205629

Update (EF Core 5.0): Updated part of the code due to metadata interface changes and introduction of skip navigations. Note that the code does not handle many-to-many skip navigation properties.

// process navigations
foreach (var navEntry in dbEntry.Navigations)
{
    if (navEntry.Metadata is not INavigation navigation) continue; // skip navigation pproperty         
    if (!visited.Add(navigation.ForeignKey)) continue; // already processed
    await navEntry.LoadAsync();
    if (!navigation.IsCollection)
    {
        // reference type navigation property
        var refValue = navigation.GetGetter().GetClrValue(entity);
        navEntry.CurrentValue = refValue == null ? null :
            await context.UpdateGraphAsync(navEntry.CurrentValue, refValue, visited);
    }
    else
    {
        // collection type navigation property
        var accessor = navigation.GetCollectionAccessor();
        var items = (IEnumerable<object>)accessor.GetOrCreate(entity, false);
        var dbItems = (IEnumerable<object>)accessor.GetOrCreate(dbEntity, false);
        var itemType = navigation.TargetEntityType;
        var keyProperties = itemType.FindPrimaryKey().Properties
            .Select((p, i) => (Index: i, Getter: p.GetGetter(), Comparer: p.GetKeyValueComparer()))
            .ToList();
        var keyValues = new object[keyProperties.Count];
        void GetKeyValues(object sourceItem)
        {
            foreach (var p in keyProperties)
                keyValues[p.Index] = p.Getter.GetClrValue(sourceItem);
        }
        object FindItem(IEnumerable<object> targetCollection, object sourceItem)
        {
            GetKeyValues(sourceItem);
            foreach (var targetItem in targetCollection)
            {
                bool keyMatch = true;
                foreach (var p in keyProperties)
                {
                    (var keyA, var keyB) = (p.Getter.GetClrValue(targetItem), keyValues[p.Index]);
                    keyMatch = p.Comparer?.Equals(keyA, keyB) ?? object.Equals(keyA, keyB);
                    if (!keyMatch) break;
                }
                if (keyMatch) return targetItem;
            }
            return null;
        }
        // Remove db items missing in the current list
        foreach (var dbItem in dbItems.ToList())
            if (FindItem(items, dbItem) == null) accessor.Remove(dbEntity, dbItem);
        // Add current items missing in the db list, update others
        var existingItems = dbItems.ToList();
        foreach (var item in items)
        {
            var dbItem = FindItem(existingItems, item);
            if (dbItem == null)
                accessor.Add(dbEntity, item, false);
            await context.UpdateGraphAsync(dbItem, item, visited);
        }
    }
}

Update:

Some additional questions arose from the comments. What to do with reference navigation properties and what to do if the related entities do not implement such generic interface, also the inability of compiler inferring the generic type arguments when using such generic method signature.

After thinking a bit I came to conclusion that no base class/interface (and even generic entity type) is needed at all, since EF Core metadata contains all information needed to work with PK (which is used by Find / FindAsync methods and change tracker for instance).

Following is a method which recursively applies disconnected entity graph modifications using only the EF Core metadata information/services. If needed, it could be modified to receive "exclusion" filter in case some entities/collections should be skipped:

public static class EntityGraphUpdateHelper
{
    public static async ValueTask<object> UpdateGraphAsync(this DbContext context, object entity) =>
        await context.UpdateGraphAsync(await context.FindEntityAsync(entity), entity, new HashSet<IForeignKey>());

    private static async ValueTask<object> UpdateGraphAsync(this DbContext context, object dbEntity, object entity, HashSet<IForeignKey> visited)
    {
        bool isNew = dbEntity == null;
        if (isNew) dbEntity = entity;
        var dbEntry = context.Entry(dbEntity);
        if (isNew)
            dbEntry.State = EntityState.Added;
        else
        {
            // ensure is attached (tracked)
            if (dbEntry.State == EntityState.Detached)
                dbEntry.State = EntityState.Unchanged;
            // update primitive values
            dbEntry.CurrentValues.SetValues(entity);
        }
        // process navigations
        foreach (var navEntry in dbEntry.Navigations)
        {
            if (!visited.Add(navEntry.Metadata.ForeignKey)) continue; // already processed
            await navEntry.LoadAsync();
            if (!navEntry.Metadata.IsCollection())
            {
                // reference type navigation property
                var refValue = navEntry.Metadata.GetGetter().GetClrValue(entity);
                navEntry.CurrentValue = refValue == null ? null :
                    await context.UpdateGraphAsync(navEntry.CurrentValue, refValue, visited);
            }
            else
            {
                // collection type navigation property
                var accessor = navEntry.Metadata.GetCollectionAccessor();
                var items = (IEnumerable<object>)accessor.GetOrCreate(entity, false);
                var dbItems = (IEnumerable<object>)accessor.GetOrCreate(dbEntity, false);
                var itemType = navEntry.Metadata.GetTargetType();
                var keyProperties = itemType.FindPrimaryKey().Properties
                    .Select((p, i) => (Index: i, Getter: p.GetGetter(), Comparer: p.GetKeyValueComparer()))
                    .ToList();
                var keyValues = new object[keyProperties.Count];
                void GetKeyValues(object sourceItem)
                {
                    foreach (var p in keyProperties)
                        keyValues[p.Index] = p.Getter.GetClrValue(sourceItem);
                }
                object FindItem(IEnumerable<object> targetCollection, object sourceItem)
                {
                    GetKeyValues(sourceItem);
                    foreach (var targetItem in targetCollection)
                    {
                        bool keyMatch = true;
                        foreach (var p in keyProperties)
                        {
                            (var keyA, var keyB) = (p.Getter.GetClrValue(targetItem), keyValues[p.Index]);
                            keyMatch = p.Comparer?.Equals(keyA, keyB) ?? object.Equals(keyA, keyB);
                            if (!keyMatch) break;
                        }
                        if (keyMatch) return targetItem;
                    }
                    return null;
                }
                // Remove db items missing in the current list
                foreach (var dbItem in dbItems.ToList())
                    if (FindItem(items, dbItem) == null) accessor.Remove(dbEntity, dbItem);
                // Add current items missing in the db list, update others
                var existingItems = dbItems.ToList();
                foreach (var item in items)
                {
                    var dbItem = FindItem(existingItems, item);
                    if (dbItem == null)
                        accessor.Add(dbEntity, item, false);
                    await context.UpdateGraphAsync(dbItem, item, visited);
                }
            }
        }
        return dbEntity;
    }

    public static ValueTask<object> FindEntityAsync(this DbContext context, object entity)
    {
        var entityType = context.Model.FindRuntimeEntityType(entity.GetType());
        var keyProperties = entityType.FindPrimaryKey().Properties;
        var keyValues = new object[keyProperties.Count];
        for (int i = 0; i < keyValues.Length; i++)
            keyValues[i] = keyProperties[i].GetGetter().GetClrValue(entity);
        return context.FindAsync(entityType.ClrType, keyValues);
    }
}

Please note that similar to EF Core methods, SaveChangesAsync call is not part of the above method, and it should be called separately afterwards.

Original:

Handling collections of entities implementing such generic interface requires slightly different approach, since there is no non generic base class / interface to be used for extracting the Id.

One possible solution is to move the collection handling code to a separate generic method and call it dynamically or via reflection.

For instance (use the VS to determine the necessary usings):

public static class EntityUpdateHelper
{
    public static async Task UpdateEntityAsync<TEntity, TId>(this DbContext context, TEntity entity, params Expression<Func<TEntity, object>>[] navigations)
        where TEntity : class, IEntity<TId>
    {
        var dbEntity = await context.FindAsync<TEntity>(entity.Id);
        var dbEntry = context.Entry(dbEntity);
        dbEntry.CurrentValues.SetValues(entity);
        foreach (var property in navigations)
        {
            var propertyName = property.GetPropertyAccess().Name;
            var dbItemsEntry = dbEntry.Collection(propertyName);
            var dbItems = dbItemsEntry.CurrentValue;
            var items = dbItemsEntry.Metadata.GetGetter().GetClrValue(entity);
            // Determine TEntity and TId, and call UpdateCollection<TEntity, TId>
            // via reflection
            var itemType = dbItemsEntry.Metadata.GetTargetType().ClrType;
            var idType = itemType.GetInterfaces()
                .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntity<>))
                .GetGenericArguments().Single();
            var updateMethod = typeof(EntityUpdateHelper).GetMethod(nameof(UpdateCollection))
                .MakeGenericMethod(itemType, idType);
            updateMethod.Invoke(null, new[] { dbItems, items });
        }

        await context.SaveChangesAsync();
    }

    public static void UpdateCollection<TEntity, TId>(this DbContext context, ICollection<TEntity> dbItems, ICollection<TEntity> items)
        where TEntity : class, IEntity<TId>
    {
        var dbItemsMap = dbItems.ToDictionary(e => e.Id);
        foreach (var item in items)
        {
            if (!dbItemsMap.TryGetValue(item.Id, out var oldItem))
                dbItems.Add(item);
            else
            {
                context.Entry(oldItem).CurrentValues.SetValues(item);
                dbItemsMap.Remove(item.Id);
            }
        }
        foreach (var oldItem in dbItemsMap.Values)
            dbItems.Remove(oldItem);
    }
}

and call it from Customer repository:

return await _context.UpdateEntityAsync(entity, e => e.Addresses);

In case of generic repository (no navigations argument), and all child collection entities implementing that interface, simple iterate the dbEntry.Collections property, e.g.

//foreach (var property in navigations)
foreach (var dbItemsEntry in dbEntry.Collections)
{
    //var propertyName = property.GetPropertyAccess().Name;
    //var dbItemsEntry = dbEntry.Collection(propertyName);
    var dbItems = dbItemsEntry.CurrentValue;
    var items = dbItemsEntry.Metadata.GetGetter().GetClrValue(entity);
    // ...
}

Upvotes: 6

Related Questions