LINQ - 使用IEnumerable<T>进行动态GroupBy

4
我有一个集合,类型为 IEnumerable<Transaction>。 Transaction 有几个属性,例如 TransactionId(Int64)、PaymentMethod(字符串)和 TransactionDate(DateTime)。
我想能够在运行时根据用户决定使用的任何分组字段动态地完成这个操作: transactions.GroupBy(x => x.PaymentMethod)
我在这里找到了大部分我需要的答案,dtb 在这里回答了这个问题:Linq GroupBy - how to specify the grouping key at runtime? 这个方法很好用:
        var arg = Expression.Parameter( typeof( Transaction ), "transaction" );
        var body = Expression.Property( arg, "PaymentMethod" );
        var lambda = Expression.Lambda<Func<Transaction, string>>( body, arg );
        var keySelector = lambda.Compile();

        var groups = transactions.GroupBy( keySelector );

除了我不知道Expression.Lambda<Func<Transaction, string>>中的Func返回类型是什么之外,其他都清楚了。 在这个示例中,它是字符串,但它也可能是Int64、decimal、DateTime等。 我不能使用Object作为返回类型,因为我可能有值类型。
我已经阅读了很多SO帖子,大部分似乎适用于IQueryable和LinqToSQL。
使用Expression类似乎是实现这一目标的好方法,但是在编译时我不知道我的组参数的名称或数据类型,有没有办法做到这一点?
我感激任何朝正确方向的推动。
编辑:
使用Polity的解决方案,我创建了一个扩展方法来实现我一直想做的事情:
    public static IEnumerable<IGrouping<object, T>> GroupBy<T>( this IEnumerable<T> items, string groupByProperty )
    {

        var arg = Expression.Parameter( typeof(T), "item" );
        var body = Expression.Convert( Expression.Property( arg, groupByProperty ), typeof( object ) );
        var lambda = Expression.Lambda<Func<T, object>>( body, arg );
        var keySelector = lambda.Compile();

        var groups = items.GroupBy( keySelector );
        return groups;
    } 

感谢Polity和所有回答者!

你能把这些代码放到一个通用方法中,并将返回类型设置为通用参数的类型吗? - ojlovecd
如果我理解你的问题,我觉得不是这样。 我仅拥有用户选择为组键的属性名称字符串。 我想我需要使用一些反射来获取类型。 - Nathan Ratcliff
我和一个同事讨论后,他建议根据分组属性的类型创建一个switch语句,为所有值类型添加一个case,并将默认情况下使用对象作为其类型。这样做是可行的,但我认为编码后需要洗个澡。 - Nathan Ratcliff
3个回答

4

根据ojlovecd的回答,我们需要在运行时实现功能。泛型和运行时并不是很容易搭配使用。但这没有问题,因为您可以将返回值视为对象,使得ojlovecd提供的非泛型方法变成如下所示:

static IEnumerable<IGrouping<object,Transaction>> GroupBy(string propName) 
{ 
    List<Transaction> transactions = new List<Transaction>  
    { 
        new Transaction{ PaymentMethod="AA", SomeDateTime=DateTime.Now.AddDays(-1), SomeDecimal=1.2M, SomeInt64=1000}, 
        new Transaction{ PaymentMethod="BB", SomeDateTime=DateTime.Now.AddDays(-2), SomeDecimal=3.4M, SomeInt64=2000}, 
        new Transaction{ PaymentMethod="AA", SomeDateTime=DateTime.Now.AddDays(-1), SomeDecimal=3.4M, SomeInt64=3000}, 
        new Transaction{ PaymentMethod="CC", SomeDateTime=DateTime.Now.AddDays(2), SomeDecimal=5.6M, SomeInt64=1000}, 
    }; 
    var arg = Expression.Parameter(typeof(Transaction), "transaction"); 
    var body = Expression.Convert(Expression.Property(arg, propName), typeof(object)); 
    var lambda = Expression.Lambda<Func<Transaction, object>>(body, arg); 
    var keySelector = lambda.Compile(); 

    var groups = transactions.GroupBy(keySelector); 
    return groups; 
} 

我之前尝试过这个。当分组值是引用类型时,它的效果非常好,但对于值类型(如Int64),你不能使用它。你会得到这个错误:System.ArgumentException: 表达式类型为'System.Int64'的对象不能用作返回类型为'System.Object'的对象。 - Nathan Ratcliff
@Nathan Ratcliff - 我稍微编辑了一下代码,包括从属性到对象的强制转换。但这并不完美,因为我怀疑许多系统都无法理解这个。例如,EF就无法理解强制转换,但它适用于linq to objects。 - Polity
谢谢,这对我很有帮助。我还在学习Expression的使用方法。 - Nathan Ratcliff

1

以下是我所指的代码,希望它们有所帮助:

static void Main(string[] args)
{

    var query = GroupBy<string>("PaymentMethod");
    foreach (var group in query)
        Console.WriteLine(group.Key + "," + group.Count());
    var query2 = GroupBy<long>("SomeInt64");
    foreach (var group in query2)
        Console.WriteLine(group.Key + "," + group.Count());
}

static IEnumerable<IGrouping<T,Transaction>> GroupBy<T>(string propName)
{
    List<Transaction> transactions = new List<Transaction> 
    {
        new Transaction{ PaymentMethod="AA", SomeDateTime=DateTime.Now.AddDays(-1), SomeDecimal=1.2M, SomeInt64=1000},
        new Transaction{ PaymentMethod="BB", SomeDateTime=DateTime.Now.AddDays(-2), SomeDecimal=3.4M, SomeInt64=2000},
        new Transaction{ PaymentMethod="AA", SomeDateTime=DateTime.Now.AddDays(-1), SomeDecimal=3.4M, SomeInt64=3000},
        new Transaction{ PaymentMethod="CC", SomeDateTime=DateTime.Now.AddDays(2), SomeDecimal=5.6M, SomeInt64=1000},
    };
    var arg = Expression.Parameter(typeof(Transaction), "transaction");
    var body = Expression.Property(arg, propName);
    var lambda = Expression.Lambda<Func<Transaction, T>>(body, arg);
    var keySelector = lambda.Compile();

    var groups = transactions.GroupBy(keySelector);
    return groups;
}

    class Transaction
    {
        public string PaymentMethod { get; set; }
        public Int64 SomeInt64 { get; set; }
        public decimal SomeDecimal { get; set; }
        public DateTime SomeDateTime { get; set; }
    }

很遗憾,我在编译时没有类型。我只有属性名称,必须在运行时确定类型。 - Nathan Ratcliff

1

如果您在编译时不知道目标类型,则需要构造委托类型并使用Expression.Call(),最后使用DynamicInvoke()执行GroupBy - 这对我有效:

var arg = Expression.Parameter(typeof(Transaction), "transaction");
var body = Expression.Property(arg, "PaymentMethod");

var delegateType = typeof(Func<,>).MakeGenericType(typeof(Transaction), body.Type);
var lambda = Expression.Lambda(delegateType, body, arg);
var source = Expression.Parameter(typeof(IEnumerable<Transaction>), "source");
var groupByExpression = Expression.Call(typeof(Enumerable), "GroupBy", 
                                        new Type[] { typeof(Transaction), body.Type }, 
                                        source, lambda);
var groupByLambda = Expression.Lambda(groupByExpression, source).Compile();

var groups = groupByLambda.DynamicInvoke(transactions);

由于在这一点上,您不能使用任何其他Linq扩展方法而不将它们转换为表达式(至少就我所理解的而言),因此其优点是值得怀疑的,所以我个人可能会选择其他提供的选项之一。


我无论如何都点赞了 - 这就是我想做的,但正如你指出的那样,除非你知道类型,否则无法使用组。谢谢你的答案。 - Nathan Ratcliff

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接