Home > Software engineering >  EF Querying DbContext of Generic type using its unique key
EF Querying DbContext of Generic type using its unique key

Time:11-28

I am creating a uni-directional sync which is I have 2 context (WebContext and StagingContext). Everytime I call SaveChangesAsync() it should sync the data updated on WebContext to StagingContext. Here's the following code I have:

Attributes

[AttributeUsage(AttributeTargets.Class)]
public class SyncEntityAttribute : Attribute
{
    public Type Target { get; set; }
}

DbContext

public override async Task<int> SaveChangesAsync(CancellationToken cancellation = default)
{
    await SyncEntityAsync(stagingContext, cancellation);

    return await base.SaveChangesAsync(cancellation);
}

private async Task SyncEntityAsync<T>(T dbContext, CancellationToken cancellation = default) where T : DbContext
{
    ChangeTracker.DetectChanges();

    var entries = ChangeTracker.Entries()
        .Where(x => Attribute.GetCustomAttribute(x.Entity.GetType(), typeof(SyncEntityAttribute)) != null)
        .ToList();

    try
    {
        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Detached || entry.State == EntityState.Unchanged)
                continue;

            var attribute = Attribute.GetCustomAttribute(entry.Entity.GetType(), typeof(SyncEntityAttribute)) as SyncEntityAttribute;
            if (attribute == null)
                continue;

            var uniqueKeys = entry.Properties.Where(x => x.Metadata.IsUniqueIndex()).ToList();

            var targetEntity = await GetQueryable(dbContext, attribute.TargetEntity); // Not yet implemented, I want this to return a single entity based on the generic type filtered by its unique key

            mapper.Map(entry.Entity, targetEntity);

            switch (entry.State)
            {
                case EntityState.Added:
                    dbContext.Add(targetEntity);
                    break;
                case EntityState.Deleted:
                    if (targetEntity.HasProperty("IsActive"))
                    {
                        targetEntity.TrySetProperty("IsActive", false);
                    }
                    break;
            }
        }
    }
    catch (Exception ex)
    {
        Log.Error(ex.Message);
    }
}

I need to sync any entity model that has the SyncEntity attribute in it. I want to query the data on the StagingContext to see if it already exists or not. In order to do this, I need to query it by its Target attribute which is a Generic Type. If I am only querying using Primary Key then this would be easy since the DbContext has FindAsync method which allows to pass Generic Types. I want to do the same with FindAsync but this time I will be filtering it using the unique key which unique are get from entry.Properties.Where(x => x.Metadata.IsUniqueIndex()).ToList();

How can I achieve this? Looking for a solution like an extension method but I can't find any in the internet.

Update based on the suggested solution:

private static async Task<object> _GetQueryable<T>(DbContext dbContext, List<PropertyEntry> uniqueKeys)
    where T : class
{
    if (uniqueKeys is null) throw new ArgumentNullException();
    if (uniqueKeys.Count <= 0) throw new ArgumentNullException();

    var p = Expression.Parameter(typeof(T));
    var filters = new List<Expression>(uniqueKeys.Count);

    foreach (var key in uniqueKeys)
    {
        var wrapper = Expression.Constant(Activator.CreateInstance(typeof(Wrapper<>).MakeGenericType(key.CurrentValue.GetType()), key.CurrentValue));
        var value = Expression.Property(wrapper, "Value");
        filters.Add(Expression.Equal(p, value));
    }

    var body = filters.Aggregate((c, n) => Expression.AndAlso(c, n));
    var predicate = Expression.Lambda<Func<T, bool>>(body, p);
    return await dbContext.Set<T>().FirstOrDefaultAsync(predicate);
}

This is my current solution for now, but executing the code filters.Add(Expression.Equal(p, value)); throws an exception The binary operator Equal is not defined for the types 'Web.Gateway.Core.StagingModels.User' and 'System.String'.. It seems like it compared the unique key to the model and not on the model property.

Update: Final code that works

private static Task<object> GetQueryable(DbContext dbContext, Type entityType, List<PropertyEntry> uniqueKeys)
{
    return (Task<object>)typeof(SharedContext).GetMethod(nameof(_GetQueryable), BindingFlags.NonPublic | BindingFlags.Static)
        .MakeGenericMethod(entityType)
        .Invoke(null, new object[] { dbContext, uniqueKeys });
}

private static async Task<object> _GetQueryable<T>(DbContext dbContext, List<PropertyEntry> uniqueKeys)
    where T : class
{
    if (uniqueKeys is null) throw new ArgumentNullException();
    if (uniqueKeys.Count <= 0) throw new ArgumentNullException();

    var entityType = typeof(T);
    var p = Expression.Parameter(entityType);
    var filters = new List<Expression>(uniqueKeys.Count);

    foreach (var key in uniqueKeys)
    {
        var wrapper = Expression.Constant(Activator.CreateInstance(typeof(Wrapper<>).MakeGenericType(key.CurrentValue.GetType()), key.CurrentValue));
        var value = Expression.Property(wrapper, "Value");
        filters.Add(Expression.Equal(Expression.Property(p, entityType.GetProperty(key.Metadata.Name)), value));
    }

    var body = filters.Aggregate((c, n) => Expression.AndAlso(c, n));
    var predicate = Expression.Lambda<Func<T, bool>>(body, p);
    return await dbContext.Set<T>().FirstOrDefaultAsync(predicate);
}

CodePudding user response:

You can create a generic version of your method GetQueryable and call it through reflection:

private static Task<object> GetQueryable(DbContext dbContext, Type entityType, List<PropertyEntry> uniqueKeys)
{
    return (Task<object>)typeof(ApplicationContext).GetMethod(nameof(_GetQueryable), BindingFlags.NonPublic | BindingFlags.Static)
        .MakeGenericMethod(entityType)
        .Invoke(null, new object[] { dbContext, uniqueKeys });
}

Then the generic version should create a predicate dynamically. Linq expression could be created with Expression class:

private static async Task<object> _GetQueryable<T>(DbContext dbContext, List<PropertyEntry> uniqueKeys)
    where T : class
{
    if (uniqueKeys is null) throw new ArgumentNullException();
    if (uniqueKeys.Count <= 0) throw new ArgumentNullException();

    var p = Expression.Parameter(typeof(T));
    var filters = new List<Expression>(uniqueKeys.Count);

    foreach (var key in uniqueKeys)
    {
        var property = Expression.Property(p, key.Metadata.PropertyInfo);
        var value = Expression.Constant(key.CurrentValue);
        filters.Add(Expression.Equal(property, value));
    }

    var body = filters.Aggregate((c, n) => Expression.AndAlso(c, n));
    var predicate = Expression.Lambda<Func<T, bool>>(body, p);
    return await dbContext.Set<T>().FirstOrDefaultAsync(predicate);
}

Depending of your expectations, you should rework your code to be more efficient.

Your entities are fetched one by one, maybe you should consider batching.

The call of _GetQueryable<T>() is done by reflection, you could consider it as a slow API (but the previous point is much important). A delegate could be created and cached for each entity type.

Generated expressions with usage of Expression.Constant will lead to non parameterized sql queries: internal cache of EF can grow (with many CompiledQuery instances) and causes some memory leaks. If your database is sqlserver, query execution plan will be different for each entity. You can create a wrapper arround the value, expose the value with a public property and replace the constant expression by a property expression:

public class Wrapper<T>
{
    public T Value { get; }

    public Wrapper(T value)
    {
        Value = value;
    }
}
//...
var wrapper = Expression.Constant(Activator.CreateInstance(typeof(Wrapper<>).MakeGenericType(key.CurrentValue.GetType()), key.CurrentValue));
var value = Expression.Property(wrapper, "Value");
  • Related