Expression.Call GroupBy then Select and Count()?

2019-06-03 10:13发布

问题:

Using Expression trees, I would need to build a GroupBy in a generic way. The static method I'm going to use is the following:

public static IQueryable<Result> GroupBySelector<TSource>(this IQueryable<TSource> source, String coloumn)
{

  //Code here

}

The Result class has two property :

public string Value { get; set; }
public int Count { get; set; }

Basically I'd like to build the following Linq query via Expression trees:

query.GroupBy(s => s.Country).Select(p => new 
                {
                    Value = p.Key,
                    Count = p.Count()
                }
            )

How would you implement it?

回答1:

Looking at:

query.GroupBy(s => s.Country).Select(p => new 
  {
    Value = p.Key,
    Count = p.Count()
  }
);

To match the signature of IQueryable<Result> what you actually need here is:

query.GroupBy(s => s.Country).Select(p => new 
  Result{
    Value = p.Key,
    Count = p.Count()
  }
);

Now, the Select can work with any IQueryable<IGrouping<string, TSource>> as is. It's only the GroupBy that needs us to use expression trees.

Our task here is to start with a type and a string that represents a property (that itself returns string) and create a Expression<Func<TSource, string>> that represents obtaining the value of that property.

So, let's produce the simple bit of the method first:

public static IQueryable<Result> GroupBySelector<TSource>(this IQueryable<TSource> source, string column)
{
    Expression<Func<TSource, string>> keySelector = //Build tree here.

    return source.GroupBy(keySelector).Select(p => new Result{Value = p.Key, Count = p.Count()});
}

Okay. How to build the tree.

We're going to need a lambda that has a paramter of type TSource:

var param = Expression.Parameter(typeof(TSource));

We're going to need to obtain the property whose name matches column:

Expression.Property(param, column);

And the only logic needed in the lambda is simply to access that property:

Expression<Func<TSource, string>> keySelector = Expression.Lambda<Func<TSource, string>>
(
  Expression.Property(param, column),
  param
);

Putting it all together:

public static IQueryable<Result> GroupBySelector<TSource>(this IQueryable<TSource> source, String column)
{
    var param = Expression.Parameter(typeof(TSource));
    Expression<Func<TSource, string>> keySelector = Expression.Lambda<Func<TSource, string>>
    (
        Expression.Property(param, column),
        param
    );
    return source.GroupBy(keySelector).Select(p => new Result{Value = p.Key, Count = p.Count()});
}

About the only thing left is the exception-handling, which I normally don't include in an answer, but one part of this is worth paying attention to.

First the obvious null and empty checks:

public static IQueryable<Result> GroupBySelector<TSource>(this IQueryable<TSource> source, String column)
{
    if (source == null) throw new ArgumentNullException("source");
    if (column == null) throw new ArgumentNullException("column");
    if (column.Length == 0) throw new ArgumentException("column");
    var param = Expression.Parameter(typeof(TSource));
    Expression<Func<TSource, string>> keySelector = Expression.Lambda<Func<TSource, string>>
    (
        Expression.Property(param, column),
        param
    );
    return source.GroupBy(keySelector).Select(p => new Result{Value = p.Key, Count = p.Count()});
}

Now, let's consider what happens if we pass a string for column that doesn't match a property of TSource. We get an ArgumentException with the message Instance property '[Whatever you asked for]' is not defined for type '[Whatever the type is]'. That's pretty much what we want in this case, so no issue.

If however we passed a string that did identify a property but where that property wasn't of type string we'd get something like "Expression of type 'System.Int32' cannot be used for return type 'System.String'". That's not dreadful, but it's not great either. Let's be more explicit:

public static IQueryable<Result> GroupBySelector<TSource>(this IQueryable<TSource> source, String column)
{
    if (source == null) throw new ArgumentNullException("source");
    if (column == null) throw new ArgumentNullException("column");
    if (column.Length == 0) throw new ArgumentException("column");
    var param = Expression.Parameter(typeof(TSource));
    var prop = Expression.Property(param, column);
    if (prop.Type != typeof(string)) throw new ArgumentException("'" + column + "' identifies a property of type '" + prop.Type + "', not a string property.", "column");
    Expression<Func<TSource, string>> keySelector = Expression.Lambda<Func<TSource, string>>
    (
        prop,
        param
    );
    return source.GroupBy(keySelector).Select(p => new Result{Value = p.Key, Count = p.Count()});
}

If this method was internal the above would perhaps be over-kill, but if it was public the extra info would be well worth it if you came to debug it.