Search code examples
c#entity-framework-core.net-6.0expression-treesef-core-6.0

EF Core 6: generic method to select distinct fields from the list


I need to write a generic method, which would return distinct values for two or more fields, given by the user in the List<string>.

With some help, I have the below, which gives "Linq cannot be translated" error.

public static Expression<Func<TSource, IDictionary<string, object>>> BuildSelectExpression<TSource>(List<string> fieldNames)
{
    var sourceType = typeof(TSource);
    var sourceParameter = Expression.Parameter(sourceType, "x");
    var dictionaryConstructor = typeof(Dictionary<string, object>).GetConstructor(Type.EmptyTypes);
    var dictionaryAddMethod = typeof(Dictionary<string, object>).GetMethod("Add");

    var elements = fieldNames.Select(fieldName =>
    {
        var property = sourceType.GetProperty(fieldName);
        if (property == null)
        {
            throw new ArgumentException($"Property '{fieldName}' does not exist on type '{sourceType}'.");
        }

        var key = Expression.Constant(fieldName);
        var value = Expression.Convert(Expression.Property(sourceParameter, property), typeof(object));
        var addMethodCall = Expression.Call(Expression.Variable(typeof(Dictionary<string, object>)), dictionaryAddMethod, key, value);
        return addMethodCall;
    }).ToArray();

    var body = Expression.Block(new[] { Expression.Variable(typeof(Dictionary<string, object>)) },
        Expression.Assign(Expression.Variable(typeof(Dictionary<string, object>)), Expression.New(dictionaryConstructor)),
        Expression.Block(elements),
        Expression.Variable(typeof(Dictionary<string, object>))
    );

    return Expression.Lambda<Func<TSource, IDictionary<string, object>>>(body, sourceParameter);
}
var selectExpression = BuildSelectExpression<MyEntityType>(fieldNames);

var distinctRecords = await _context.MyEntityDbSet
                     .Select(selectExpression)
                     .Distinct(new DictionaryComparer<string, object>())
                     .ToListAsync(cancellationToken);

And the DictionacyComparer method:

public class DictionaryComparer<TKey, TValue> : IEqualityComparer<IDictionary<TKey, TValue>>
{
    public bool Equals(IDictionary<TKey, TValue> x, IDictionary<TKey, TValue> y)
    {
        if (x == y) return true;
        if (x == null || y == null) return false;
        if (x.Count != y.Count) return false;

        return x.Keys.All(key => y.TryGetValue(key, out var value) && EqualityComparer<TValue>.Default.Equals(x[key], value));
    }

    public int GetHashCode(IDictionary<TKey, TValue> obj)
    {
        unchecked
        {
            int hash = 17;
            foreach (var entry in obj.OrderBy(e => e.Key.GetHashCode()))
            {
                hash = hash * 23 + entry.Key.GetHashCode();
                hash = hash * 23 + (entry.Value?.GetHashCode() ?? 0);
            }
            return hash;
        }
    }
}

Edit:

When I know the fields, let's say "customer_id", "customer_name" I can simply write:

dbSet
   .Select(x=> new {x.customer_id, x.customer_name})
   .Distinct()
   .ToListAsync()

The challenge is when the list of field names comes as input.


Solution

  • This is implementation of selecting Distinct values. One limitation, properties which are examined for uniqueness should be writable (with setter).

    Usage is simple:

    var result = await dbSet
        .SelectDistinctValues(new List<string> { "customer_id", "customer_name" })
        .ToListAsync();
    

    As a bonus complex properties are also supported:

    var result = await dbSet
        .SelectDistinctValues(new List<string> { "customer.id", "customer.name" })
        .ToListAsync();
    

    And implementation:

    public static class QueryableExtensions
    {
        static readonly ConstructorInfo _dictionaryConstructor = typeof(Dictionary<string, object>).GetConstructor(Type.EmptyTypes) ?? throw new InvalidOperationException();
        static readonly MethodInfo _dictionaryAddMethodInfo = typeof(Dictionary<string, object>).GetMethod("Add") ?? throw new InvalidOperationException();
    
        public static IQueryable<IDictionary<string, object>> SelectDistinctValues<TSource>(this IQueryable<TSource> source,
            List<string> fieldNames)
        {
            var fields = fieldNames.Select(f => f.Split('.')).ToList();
    
            var param = Expression.Parameter(typeof(TSource), "e");
    
            var selectorBody = BuildSelectExpression(param, fields, 0);
            var selector = Expression.Lambda<Func<TSource, TSource>>(selectorBody, param);
    
            var dictionarySelectorBody = BuildDictionarySelectExpression(param, fields);
            var dictionarySelector = Expression.Lambda<Func<TSource, IDictionary<string, object>>>(dictionarySelectorBody, param);
    
            var query = source
                .Select(selector)
                .Distinct()
                .Select(dictionarySelector);
    
            return query;
        }
    
    
        static Expression BuildDictionarySelectExpression(Expression objParam, List<string[]> fieldNames)
        {
            var body = (Expression)Expression.ListInit(
                Expression.New(_dictionaryConstructor),
                fieldNames.Select(propName =>
                    Expression.ElementInit(_dictionaryAddMethodInfo, Expression.Constant(string.Join(".", propName)),
                        Expression.Convert(MakePropPath(objParam, propName), typeof(object)))
            ));
    
            return body;
        }
    
    
        static Expression BuildSelectExpression(Expression objPath, List<string[]> fieldNames, int level)
        {
            if (fieldNames.Count == 0)
                throw new InvalidOperationException();
    
            var objType = objPath.Type;
    
            var grouped = fieldNames.GroupBy(path => path[level])
                .Select(x => new
                {
                    PropInfo = objType.GetProperty(x.Key) ?? throw new InvalidOperationException(),
                    NexPath = x.Where(x => x.Length > level + 1).ToList()
                }).OrderBy(x => x.NexPath.Count > 0);
    
            var assignments = grouped.Select(x =>
            {
                var access = Expression.MakeMemberAccess(objPath, x.PropInfo);
                return Expression.Bind(x.PropInfo,
                    x.NexPath.Count == 0
                        ? access
                        : BuildSelectExpression(access, x.NexPath, level + 1));
            });
    
            var newExpr = Expression.MemberInit(
                Expression.New(objType),
                assignments
            );
    
            return newExpr;
        }       
    
        static Expression MakePropPath(Expression objExpression, string[] path)
        {
            return path.Aggregate(objExpression, Expression.PropertyOrField);
        }
    }