8
\$\begingroup\$

Linq to Object GroupBy extension method allows grouping by projection:

IEnumerable<Product> products = …
var groups = products.GroupBy(p => new { p.Price, p.Category });

Here is the solution to specify field names at runtime in a way like this:

IEnumerable<IGrouping<Product, Product>> groups = 
     products.GroupBy("Price",  "Category");

Supports IQueryable<T> and IEnumerable<T> where T is Entity or Anonymous Type, tested with EF6.

Just one class to simplify copy/paste:

public static class PropertyGrouping
{
    public static IEnumerable<IGrouping<T, T>> GroupBy<T>(this IEnumerable<T> source, params string[] fields) =>
        source.GroupBy(
            typeof(T).IsAnonymous()
                ? source.SelectAnonymous(fields)
                : source.SelectEntity(fields),
            new PropertyComparer<T>(fields));

    class PropertyComparer<T> : IEqualityComparer<T>
    {
        public PropertyComparer(params string[] properties)
        {
            var type = typeof(T);
            Properties = properties
                .Select(name => type.GetProperty(name))
                .ToArray();
        }

        IEnumerable<PropertyInfo> Properties { get; }

        public bool Equals(T x, T y) =>
            Properties.Aggregate(true, (eq, p) => eq && Equals(p.GetValue(x), p.GetValue(y)));

        public int GetHashCode(T obj) =>
            Properties.Aggregate(0, (h, p) => h ^ (p.GetValue(obj)?.GetHashCode() ?? 0));
    }

    static Func<T, T> SelectAnonymous<T>(this IEnumerable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var itemParam = Expression.Parameter(itemType, "x");
        var ctor = itemType.GetConstructors().Single();
        var selector =
            Expression.Lambda<Func<T, T>>(
                Expression.New(ctor,
                    ctor.GetParameters()
                        .Select(p => fields.Contains(p.Name)
                            ? (Expression)Expression.PropertyOrField(itemParam, p.Name)
                            : (Expression)Expression.Constant(p.ParameterType.IsValueType
                                ? Activator.CreateInstance(p.ParameterType) : null,
                                p.ParameterType))
                        .ToArray(),
                    itemType.GetProperties().Cast<MemberInfo>().ToArray()),
                itemParam);

        return selector.Compile();
    }

    static Func<T, T> SelectEntity<T>(this IEnumerable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var groupType = itemType;
        var itemParam = Expression.Parameter(itemType, "x");
        var bindings = from f in fields
                       select Expression.Bind(
                           groupType.GetMember(f).Single(),
                           Expression.PropertyOrField(itemParam, f));

        var selector = Expression.MemberInit(Expression.New(groupType), bindings.ToArray());
        return Expression
            .Lambda<Func<T, T>>(selector, itemParam)
            .Compile();
    }

    public static IQueryable<IGrouping<T, T>> GroupBy<T>(this IQueryable<T> source, params string[] fields) => typeof(T).IsAnonymous()
        ? source.GroupAnonymousBy(fields)
        : source.GroupEntitiesBy(fields);

    static Boolean IsAnonymous(this Type type)
    {
        var hasCompilerGeneratedAttribute = type.GetCustomAttributes(typeof(CompilerGeneratedAttribute), false).Count() > 0;
        var nameContainsAnonymousType = type.FullName.Contains("AnonymousType");
        return hasCompilerGeneratedAttribute && nameContainsAnonymousType;
    }

    static IQueryable<IGrouping<T, T>> GroupAnonymousBy<T>(this IQueryable<T> source, params string[] fields)
    {
        var itemType = typeof(T);
        var itemParam = Expression.Parameter(itemType, "x");
        var ctor = itemType.GetConstructors().Single();
        var selector =
            Expression.Lambda<Func<T, T>>(
                Expression.New(ctor,
                    ctor.GetParameters()
                        .Select(p => fields.Contains(p.Name) 
                            ? (Expression)Expression.PropertyOrField(itemParam, p.Name) 
                            : (Expression)Expression.Constant(p.ParameterType.IsValueType
                                ? Activator.CreateInstance(p.ParameterType) : null, 
                                p.ParameterType))
                        .ToArray(),
                    itemType.GetProperties().Cast<MemberInfo>().ToArray()),
                itemParam);

        return source.GroupBy(selector);
    }

    static IQueryable<IGrouping<T, T>> GroupEntitiesBy<T>(this IQueryable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var method = typeof(Queryable).GetMethods()
                     .Where(m => m.Name == "GroupBy")
                     .Single(m => m.GetParameters().Length == 2)
                     .MakeGenericMethod(itemType, itemType.Derive());

        var result = method.Invoke(null, new object[] { source, source.Select(fields) });
        return (IQueryable<IGrouping<T, T>>)result;
    }

    static Expression Select<T>(this IQueryable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var groupType = itemType.Derive();
        var itemParam = Expression.Parameter(itemType, "x");
        var bindings = from f in fields
                       select Expression.Bind(
                           groupType.GetMember(f).Single(),
                           Expression.PropertyOrField(itemParam, f));

        var selector = Expression.MemberInit(Expression.New(groupType), bindings.ToArray());
        return Expression.Lambda(selector, itemParam);
    }

    static ConcurrentDictionary<Type, Type> GroupTypes { get; } = new ConcurrentDictionary<Type, Type>();

    static Type Derive(this Type baseType) => GroupTypes.GetOrAdd(baseType, t =>
    {
        var assemblyName = new AssemblyName($"TempAssm_{Guid.NewGuid():N}");
        var assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
        var moduleBuilder = assemblyBuilder.DefineDynamicModule("core");
        var groupTypeName = $"{baseType}_{Guid.NewGuid():N}";
        var typeBuilder = moduleBuilder.DefineType(groupTypeName);
        var notMappedCtor = typeof(NotMappedAttribute).GetConstructor(new Type[0]);
        var notMappedBuilder = new CustomAttributeBuilder(notMappedCtor, new object[0]);
        typeBuilder.SetParent(baseType);
        typeBuilder.SetCustomAttribute(notMappedBuilder);
        return typeBuilder.CreateType();
    });
}
\$\endgroup\$
  • 1
    \$\begingroup\$ This is the coolest real-world example of how useful expressions are ;-) \$\endgroup\$ – t3chb0t Aug 16 '17 at 7:04
  • \$\begingroup\$ Agree with @t3chb0t. Really nice code! \$\endgroup\$ – Maxim Aug 17 '17 at 9:04
  • \$\begingroup\$ :O :O this is magical! Question: Do you know how I can edit this code so it supports sub-types? for example within an OrderModel there is a Contact, and I want to filter on ContactId of Contact? So that I can insert Contact.ContactId as property field? Would be amazing! Question is here: stackoverflow.com/questions/55616916/… \$\endgroup\$ – CularBytes Apr 17 '19 at 10:46
  • \$\begingroup\$ Another question: Is it true that the T models that are consuming this, are all implementing IEquatable or have overriden GetHashCode and/or Equals? Because without it, it seems to not group them correctly. Equals or GetHasCode within PropertComparer<T> seems not to be called. \$\endgroup\$ – CularBytes Apr 17 '19 at 11:08
  • \$\begingroup\$ After with a fair amount of trial getting this to work with .net core I get the following error. Client side GroupBy is not supported. \$\endgroup\$ – ᴍᴀᴛᴛ ʙᴀᴋᴇʀ Jun 12 '20 at 11:42
3
\$\begingroup\$

Here are a few things I noticed:


public PropertyComparer(params string[] properties)
{
  var type = typeof(T);
  Properties = properties
      .Select(name => type.GetProperty(name))
      .ToArray();
}

There is no need for the lambda and it could be written as:

    Properties = properties
        .Select(type.GetProperty)
        .ToArray();

but for this to be clear the name properties should be changed to propertyNames.


public bool Equals(T x, T y) =>
          Properties.Aggregate(true, (eq, p) => eq && Equals(p.GetValue(x), p.GetValue(y)));

This Aggregate could simplified with All which would stop checking other properties if the result of Equals would be false for any of them:

public bool Equals(T x, T y) => Properties.All(p => Equals(p.GetValue(x), p.GetValue(y)))

var selector =
  Expression.Lambda<Func<T, T>>(
      Expression.New(ctor,
          ctor.GetParameters()
              .Select(p => fields.Contains(p.Name)
                  ? (Expression)Expression.PropertyOrField(itemParam, p.Name)
                  : (Expression)Expression.Constant(p.ParameterType.IsValueType
                      ? Activator.CreateInstance(p.ParameterType) : null,
                      p.ParameterType))
              .ToArray(),
          itemType.GetProperties().Cast<MemberInfo>().ToArray()),
      itemParam);

Unless I didn't find the difference this exact same block of code seems to occur twice in your code, both in the SelectAnonymous and the GroupAnonymousBy.


The method GroupEntitiesBy uses a magic number 2 in this line

.Single(m => m.GetParameters().Length == 2)

I guess you are looking here for a GroupBy with two parameters.

var method = typeof(Queryable).GetMethods()
           .Where(m => m.Name == "GroupBy")
           .Single(m => m.GetParameters().Length == 2)
           .MakeGenericMethod(itemType, itemType.Derive());

It'd be safer to serch for parameters by type I think rather then relying on the count. If for whatever reason they add another GroupBy overload with two parameters in future it could break. The same goes for Single. Also the string "GroupBy" could be replaced with nameof(Queryable.GroupBy).


var notMappedCtor = typeof(NotMappedAttribute).GetConstructor(new Type[0]);

This line in the Derive method could use Type.EmptyTypes in place of the empty array.

I also don't like the huge lambda here GroupTypes.GetOrAdd(baseType, t =>. It would make more sense to make it a testable helper method in another class than immediately saving and hiding the result in a dictionary.


Other than these few nitpicks I find this an amazingly clean and useful piece of code that demonstrates how practical expression-trees and dynamic code generation could be and I have to study it more ;-)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.