C# LINQ, dynamic grouping by [Key] attributes

1.2k views Asked by At

Consider the following classes:

public class Potato
{
    [Key]
    public string Farm { get; set; }
    [Key]
    public int Size { get; set; }
    public string Trademark { get; set; }
}

public class Haybell
{
    [Key]
    public string color { get; set; }
    public int StrawCount { get; set; }
}

public class Frog
{
    [Key]
    public bool IsAlive { get; set; }
    [Key]
    public bool IsVirulent { get; set; }
    public byte LimbCount { get; set; } = 4;
    public ConsoleColor Color { get; set; }
}

Each class has properties with [Key] attribute. Is it possible to dynamically group an IEnumerable of any of these classes by their respective [Key] attributes?

3

There are 3 answers

0
Nick Farsi On BEST ANSWER

Somebody had posted a valid answer and removed it later for some reason. Here it is:

Combined key class:

class CombinedKey<T> : IEquatable<CombinedKey<T>>
{
    readonly object[] _keys;

    public bool Equals(CombinedKey<T> other)
    {
        return _keys.SequenceEqual(other._keys);
    }

    public override bool Equals(object obj)
    {
        return obj is CombinedKey<T> key && Equals(key);
    }

    public override int GetHashCode()
    {
        int hash = _keys.Length;
        foreach (object o in _keys)
        {
            if (o != null)
            {
                hash = hash * 13 + o.GetHashCode();
            }
        }
        return hash;
    }

    readonly Lazy<Func<T, object[]>> lambdaFunc = new Lazy<Func<T, object[]>>(() =>
    {
        Type type = typeof(T);
        var paramExpr = Expression.Parameter(type);
        var arrayExpr = Expression.NewArrayInit(
            typeof(object),
            type.GetProperties()
                .Where(p => (Attribute.GetCustomAttribute(p, typeof(KeyAttribute)) != null))
                .Select(p => Expression.Convert(Expression.Property(paramExpr, p), typeof(object)))
                .ToArray()
            );

        return Expression.Lambda<Func<T, object[]>>(arrayExpr, paramExpr).Compile();
    }, System.Threading.LazyThreadSafetyMode.PublicationOnly);

    public CombinedKey(T instance)
    {
        _keys = lambdaFunc.Value(instance);
    }
}

Caller function and the actual usage:

public static class MyClassWithLogic
{
    //Caller to CombinedKey class
    private static CombinedKey<Q> NewCombinedKey<Q>(Q instance)
    {
        return new CombinedKey<Q>(instance);
    }

    //Extension method for IEnumerables
    public static IEnumerable<T> DistinctByPrimaryKey<T>(this IEnumerable<T> entries) where T : class
    {
        return entries.AsQueryable().GroupBy(NewCombinedKey)
            .Select(r => r.First());
    }
}

Yes, it is relatively slow, so if it is a problem, then Klaus Gütter's solutions are the way to go.

2
Klaus Gütter On

I would go for adding extension methods for each your types, like

Option 1:

static class Extensions 
{
    public static IEnumerable<IGrouping<Tuple<string, int>, Potato>>
       GroupByPrimaryKey(this IEnumerable<Potato> e)
    {
        return e.GroupBy(p => Tuple.Create(p.Farm, p.Size));
    }

    public static IEnumerable<IGrouping<Tuple<bool, bool>, Frog>>
       GroupByPrimaryKey(this IEnumerable<Frog> e)
    {
        return e.GroupBy(p => Tuple.Create(p.IsAlive, p.IsVirulent));
    }
}

If there are lots of types, you may generate the code using t4.

Usage: .GroupByPrimaryKey().

Option 2:

A simpler variation:

static class Extensions 
{
    public static Tuple<string, int> GetPrimaryKey(this Potato p)
    {
        return Tuple.Create(p.Farm, p.Size);
    }
    public static Tuple<bool, bool> GetPrimaryKey(this Frog p)
    {
        return Tuple.Create(p.IsAlive, p.IsVirulent);
    }

}

Usage: .GroupBy(p => p.GetPrimaryKey()).

Option 3:

A solution with reflection is possible, but will be slow. Sketch (far from production-ready!)

class CombinedKey : IEquatable<CombinedKey>
{
    object[] _keys;
    CombinedKey(object[] keys)
    {
        _keys = keys;
    }
    
    public bool Equals(CombinedKey other)
    {
        return _keys.SequenceEqual(other._keys);
    }
    
    public override bool Equals(object obj)
    {
        return obj is CombinedKey && Equals((CombinedKey)obj);
    }
    
    public override int GetHashCode()
    {
        return 0;
    }

    public static CombinedKey GetKey<T>(T instance)
    {
        return new CombinedKey(GetKeyAttributes(typeof(T)).Select(p => p.GetValue(instance, null)).ToArray());
    }

    private static PropertyInfo[] GetKeyAttributes(Type type)
    {
        // you definitely want to cache this
        return type.GetProperties()
            .Where(p => Attribute.GetCustomAttribute(p, typeof(KeyAttribute)) != null)
            .ToArray();
    }
}   

Usage: GroupBy(p => CombinedKey.GetKey(p))

1
Ian Mercer On

The challenge here is that you need to build an anonymous type in order to have a GroupBy Expression that can translate to SQL or any other LINQ provider.

I'm not sure that you can do that using reflection (not without some really complex code to create an anonymous type at runtime). But you could create the grouping expression if you were willing to provide an example of the anonymous type as the seed.

public static Expression<Func<TSource, TAnon>> GetAnonymous<TSource,TAnon>(TSource dummy, TAnon example)
{
  var ctor = typeof(TAnon).GetConstructors().First();
  var paramExpr = Expression.Parameter(typeof(TSource));
  return Expression.Lambda<Func<TSource, TAnon>>
  (
      Expression.New
      (
          ctor,
          ctor.GetParameters().Select
          (
              (x, i) => Expression.Convert
              (
                  Expression.Property(paramExpr, x.Name),   // fetch same named property
                  x.ParameterType
              )
          )
      ), paramExpr);
}

And here's how you would use it (Note: the dummy anonymous type passed to the method is there in order to make the anonymous type a compile-time Type, the method doesn't care what the values are that you pass in for it.) :

static void Main()
{
    
    var groupByExpression = GetAnonymous(new Frog(), new {IsAlive = true, IsVirulent = true});
    
    Console.WriteLine(groupByExpression);
    
    var frogs = new []{ new Frog{ IsAlive = true, IsVirulent = false}, new Frog{ IsAlive = false, IsVirulent = true}, new Frog{ IsAlive = true, IsVirulent = true}};
    
    var grouped = frogs.AsQueryable().GroupBy(groupByExpression);
    
    foreach (var group in grouped)
    {
       Console.WriteLine(group.Key);    
    }
    
}   

Which produces:

Param_0 => new <>f__AnonymousType0`2(Convert(Param_0.IsAlive, Boolean), Convert(Param_0.IsVirulent, Boolean))
{ IsAlive = True, IsVirulent = False }
{ IsAlive = False, IsVirulent = True }
{ IsAlive = True, IsVirulent = True }