在LINQ语句中使用部分类属性

23

我试图找出做我认为很容易的事情的最佳方法。我有一个名为Line的数据库模型,代表发票中的一行。

它大致看起来像这样:

public partial class Line 
{
    public Int32 Id { get; set; }
    public Invoice Invoice { get; set; }
    public String Name { get; set; }
    public String Description { get; set; }
    public Decimal Price { get; set; }
    public Int32 Quantity { get; set; }
}

这个类是从数据库模型生成的。
我还有另一个类,它添加了一个属性:

public partial class Line
{
    public Decimal Total
    {
        get
        {
            return this.Price * this.Quantity
        }
    }
}

现在,从我的客户控制器中,我想要做这样的事情:

var invoices = ( from c in _repository.Customers
                         where c.Id == id
                         from i in c.Invoices
                         select new InvoiceIndex
                         {
                             Id = i.Id,
                             CustomerName = i.Customer.Name,
                             Attention = i.Attention,
                             Total = i.Lines.Sum( l => l.Total ),
                             Posted = i.Created,
                             Salesman = i.Salesman.Name
                         }
        )
但是由于臭名昭著的

,我无法感谢。
The specified type member 'Total' is not supported in LINQ to Entities. Only initializers, entity members, and entity navigation properties are supported.
什么是最好的重构方式,使其能够正常工作?
我已经尝试过LinqKit、i.Lines.AsEnumerable()和将i.Lines放入我的InvoiceIndex模型中,并使其计算视图的总和。
最后一个解决方案“有效”,但我无法对该数据进行排序。 最终我想做的是:
var invoices = ( from c in _repository.Customers
                         ...
        ).OrderBy( i => i.Total )

我想对我的数据进行分页,所以我不想浪费时间将整个 c.Invoices 转换为列表使用 .AsEnumerable()。

赏金

我知道这对某些人来说可能是一个相当大的问题。经过数小时在互联网上搜寻,我得出的结论是没有一个令人满意的解决方案。然而,我相信这对那些正在尝试使用ASP MVC进行分页和排序的人来说必须是一个相当常见的障碍。 我理解该属性无法映射到SQL,因此您无法在分页之前对其进行排序,但我要找的是一种获取所需结果的方法。

完美解决方案的要求:

  • DRY,也就是我的总计算只存在于一个地方
  • 支持排序和分页,并按照该顺序排序
  • 不使用 .AsEnumerable 或 .AsArray 将整个数据表拉入内存

我真正希望找到的是一种在扩展的部分类中指定Linq to entities SQL的方法。但是我被告知这是不可能的。请注意,解决方案不需要直接使用 Total 属性。从IQueryable调用该属性根本不受支持。我正在寻找一种通过不同方法实现相同结果的方式,但同样简单和正交。

赏金的获胜者将是最终投票最高的解决方案,除非有人发布完美的解决方案 :)

在阅读答案之前忽略以下内容:

{1}使用Jacek的解决方案,我进一步实现了可通过LinqKit调用的属性。这样,即使是 .AsQueryable().Sum() 也被包含在我们的部分类中。以下是我现在正在做的一些示例:

public partial class Line
{
    public static Expression<Func<Line, Decimal>> Total
    {
        get
        {
            return l => l.Price * l.Quantity;
        }
    }
}

public partial class Invoice
{
    public static Expression<Func<Invoice, Decimal>> Total
    {
        get
        {
            return i => i.Lines.Count > 0 ? i.Lines.AsQueryable().Sum( Line.Total ) : 0;
        }
    }
}

public partial class Customer
{
    public static Expression<Func<Customer, Decimal>> Balance
    {
        get
        {
            return c => c.Invoices.Count > 0 ? c.Invoices.AsQueryable().Sum( Invoice.Total ) : 0;
        }
    }
}

第一个技巧是.Count的检查。这些是必需的,因为我想您不能在空集上调用.AsQueryable。您会收到有关Null实例化的错误。

有了这三个部分类,现在您可以使用技巧,例如

var customers = ( from c in _repository.Customers.AsExpandable()
                           select new CustomerIndex
                           {
                               Id = c.Id,
                               Name = c.Name,
                               Employee = c.Employee,
                               Balance = Customer.Balance.Invoke( c )
                           }
                    ).OrderBy( c => c.Balance ).ToPagedList( page - 1, PageSize );

var invoices = ( from i in _repository.Invoices.AsExpandable()
                         where i.CustomerId == Id 
                         select new InvoiceIndex
                        {
                            Id = i.Id,
                            Attention = i.Attention,
                            Memo = i.Memo,
                            Posted = i.Created,
                            CustomerName = i.Customer.Name,
                            Salesman = i.Salesman.Name,
                            Total = Invoice.Total.Invoke( i )
                        } )
                        .OrderBy( i => i.Total ).ToPagedList( page - 1, PageSize );

非常酷。

有一个问题,LinqKit不支持调用属性,你会收到一个有关尝试将PropertyExpression强制转换为LambaExpression的错误。有两种解决方法。首先是像这样自己提取表达式:

var tmpBalance = Customer.Balance;
var customers = ( from c in _repository.Customers.AsExpandable()
                           select new CustomerIndex
                           {
                               Id = c.Id,
                               Name = c.Name,
                               Employee = c.Employee,
                               Balance = tmpBalance.Invoke( c )
                           }
                    ).OrderBy( c => c.Balance ).ToPagedList( page - 1, PageSize );

我觉得这有点儿傻。因此,我修改了LinqKit,使其在遇到属性时提取get{}值。它对表达式的操作方式类似于反射,所以编译器不会为我们解析Customer.Balance。我在ExpressionExpander.cs中的TransformExpr函数中进行了三行更改。这可能不是最安全的代码,而且可能会破坏其他东西,但它目前可用,并且我已经通知了作者有关缺陷的情况。

Expression TransformExpr (MemberExpression input)
{
        if( input.Member is System.Reflection.PropertyInfo )
        {
            return Visit( (Expression)( (System.Reflection.PropertyInfo)input.Member ).GetValue( null, null ) );
        }
        // Collapse captured outer variables
        if( input == null

实际上,我可以十分肯定地说,这段代码可能会破坏一些东西,但目前它能够工作就已经足够好了。 :)


1
我认为你最近的更新应该成为一个独立的答案。 - ErikE
6个回答

23

还有一种方式,它稍微复杂一些,但是可以让你封装这个逻辑。

public partial class Line
{
    public static Expression<Func<Line,Decimal>> TotalExpression
    {
        get
        {
            return l => l.Price * l.Quantity
        }
    }
}

然后重新编写查询以便

var invoices = ( from c in _repository.Customers
                     where c.Id == id
                     from i in c.Invoices
                     select new InvoiceIndex
                     {
                         Id = i.Id,
                         CustomerName = i.Customer.Name,
                         Attention = i.Attention,
                         Total = i.Lines.AsQueryable().Sum(Line.TotalExpression),
                         Posted = i.Created,
                         Salesman = i.Salesman.Name
                     }
               )

它对我有效,可在服务器端执行查询并符合DRY原则。


+1:不错!我本来以为在 LINQ to Entities 投影中使用 AsQueryable 是不可能的。但它确实可以工作(我刚测试过)。将会记住这个好“技巧”。 - Slauma
我真的很喜欢这个解决方案。目前,我正在尝试使用 LinqKit 来使属性可调用,从而将该行转换为 Total = Invoice.Total.Invoke( i ) :D。目前,LinqKit 似乎不支持调用属性,但我认为这是可以做到的。 - Charles
好的,长话短说,我已经让linqkit工作了,这是一个相当不错的解决方案 :) 请查看问题下面{1}处的代码编辑。 - Charles
是的,干得好。我在努力使用DevExpress XPO来创建动态Linq查询时也做了类似的事情。当然,如果您使用属性的属性等,它会出现问题。不过这仍然带来了巨大的满足感 :) - Jacek Gorgoń

5

你的额外属性只是从模型中的数据计算出来的,但这个属性不是EF可以自然地翻译的。SQL完全能够执行相同的计算,并且EF可以翻译这个计算。如果需要在查询中使用它,请使用它代替属性。

Total = i.Lines.Sum( l => l.Price * l.Quantity)

1
这个可以运行,但是假设我在5个不同的地方都这样做了,一个月后我需要添加税费计算或运费,或者根据物品重量增加额外费用... 我希望将“总计”计算封装在Line模型中。 - Charles
我在想是否有可能让EF了解我的代码中的类型计算...比如说,当构建此值的SQL命令时,可以指定“输出[价格×数量]”。这将是非常好的。 - Charles
2
@Charles:不,无法将自定义计算映射到属性。您的linq-to-entities查询只能包含已映射(持久化)的属性。自定义计算可以包装在模型定义函数中,但这更加复杂。 - Ladislav Mrnka

3
尝试像这样做:

试着这样做:

var invoices =
    (from c in _repository.Customers
    where c.Id == id
    from i in c.Invoices
    select new 
    {
        Id = i.Id,
        CustomerName = i.Customer.Name,
        Attention = i.Attention,
        Lines = i.Lines,
        Posted = i.Created,
        Salesman = i.Salesman.Name
    })
    .ToArray()
    .Select (i =>  new InvoiceIndex
    {
        Id = i.Id,
        CustomerName = i.CustomerName,
        Attention = i.Attention,
        Total = i.Lines.Sum(l => l.Total),
        Posted = i.Posted,
        Salesman = i.Salesman
    });

基本上,它要查询数据库中你所需的记录,将它们带入内存,然后一旦数据在那里就进行求和。


ToArray可以被省略。相反,使用AsEnumerable(),它会隐藏IQueryable从而帮助保持流式处理的特性,而不是缓冲/分配数组存储。 - Drew Marsh
现在这个很有趣。我没有想到可以像这样使用匿名类型。我目前正在使用类似 AsEnumerable 的东西,但据我所知,使用 ToArray 和 AsEnumerable 会导致 SQL 链结束,代码将获取所有发票数据,然后才允许我进行总计算。这是不可取的,因为我正在分页结果,并且我只需要一次处理大约10张发票。我可能不得不放弃那个要求,我只能按照 Anthony 的解决方案去做了。 - Charles
@Charles - 你能不能在ToArray/AsEnumerable调用之前进行分页呢? - Enigmativity
但是按ID排序的第4页将与按Total排序的第4页不同。我可以切换到分页然后排序,但对于按字母顺序排序来说会有点奇怪。 - Charles
我认为我最终可能会做的是扩展我的排序器,这样当它对表数据进行排序时,它使用简单的.OrderBy,但如果它对计算字段进行排序,则首先使用.AsEnumerable。这样,我的用户只有在对“坏”字段进行排序时才会受到性能影响。 - Charles

3
我认为解决这个问题最简单的方法是使用DelegateDecompiler.EntityFramework(由Alexander Zaytsev制作),这是一个能够将委托或方法体反编译为其lambda表示形式的库。

解释:

假设我们有一个带有计算属性的类

class Employee
{
    [Computed]
    public string FullName
    {
        get { return FirstName + " " + LastName; }
    }

    public string LastName { get; set; }

    public string FirstName { get; set; }
}

你需要通过员工的全名进行查询

var employees = (from employee in db.Employees
                 where employee.FullName == "Test User"
                 select employee).Decompile().ToList();

当您调用 .Decompile 方法时,它将计算属性反编译为其底层表示形式,查询将变得类似于以下查询。
var employees = (from employee in db.Employees
                 where (employee.FirstName + " " + employee.LastName)  == "Test User"
                 select employee).ToList();

如果你的类没有 [Computed] 属性,你可以使用 .Computed() 扩展方法。
var employees = (from employee in db.Employees
                 where employee.FullName.Computed() == "Test User"
                 select employee).ToList();

此外,您也可以像这样调用返回单个项(Any、Count、First、Single等)以及其他方法的方法:
bool exists = db.Employees.Decompile().Any(employee => employee.FullName == "Test User");

再次,FullName属性将被反编译:

bool exists = db.Employees.Any(employee => (employee.FirstName + " " + employee.LastName) == "Test User");

使用EntityFramework进行异步操作

DelegateDecompiler.EntityFramework包提供了DecompileAsync扩展方法,为EF的异步操作提供支持。


此外

你可以在这里找到8种将一些属性值混合在一起的方法:EF

计算属性和Entity Framework。 (作者:Dave Glick


现在有一个真正的软件包来支持这一点,这太好了! - Charles
@Charles,是的,那是一个不错且有用的包。此外,我在我的答案中添加了其他方法的参考。 - Ramin Bateni

1

虽然这不是你问题的答案,但它可能是你问题的解决方案:

既然你似乎愿意修改数据库,为什么不在数据库中创建一个新视图,基本上是

create view TotalledLine as
select *, Total = (Price * Quantity)
from LineTable;

然后将您的数据模型更改为使用TotalledLine而不是LineTable?

毕竟,如果您的应用程序需要总数,那么想象其他人可能需要它也不是什么难事,将这种类型的计算集中到数据库中是使用数据库的原因之一。


我曾考虑过使用视图,但我认为实体框架不能很容易地与它们一起工作,并且它们在数据库设计器中也无法设计。 - Charles
查询和视图设计器用于维护视图,EF 与视图的配合也非常良好。您关心哪些问题? - NetMage
计算也足够简单,您可以触发更新,或者实现一个“关闭订单”工作流,最终确定一些值(无论是否有任何DBMS支持)。就我个人而言,近年来我尽量保持DB-agnostic,过去的10年中,我见过更多的后端交换,这比我愿意承认的还要多...并且DBMS文物一直是处理最昂贵的文物之一。 - Shaun Wilson

0

在这里补充一些我的想法。因为我是一个完美主义者,所以我不想将整个表的内容都加载到内存中,只是为了进行计算、排序和分页。所以,某种程度上,表需要知道总数。

我想的是,在行表中创建一个名为“CalcTotal”的新字段,其中包含行的计算总数。每次更改行时,该值都会根据.Total的值进行设置。

这有两个优点。首先,我可以将查询更改为:

var invoices = ( from c in _repository.Customers
                     where c.Id == id
                     from i in c.Invoices
                     select new InvoiceIndex
                     {
                         Id = i.Id,
                         CustomerName = i.Customer.Name,
                         Attention = i.Attention,
                         Total = i.Lines.Sum( l => l.CalcTotal ),
                         Posted = i.Created,
                         Salesman = i.Salesman.Name
                     }
    )

排序和分页将会起作用,因为这是一个数据库字段。我可以在控制器中进行检查(line.CalcTotal == line.Total),以便检测任何愚弄行为。这确实会在我的存储库中造成一些额外的开销,特别是当我要保存或创建新行时,我需要添加。

line.CalcTotal = line.Total

但我认为这可能是值得的。

为什么要费力地拥有两个具有相同数据的属性呢?

嗯,事实上,我可以放置

line.CalcTotal = line.Quantity * line.Price;

在我的代码库中。但这并不是很正交的,如果需要对行总数进行更改,编辑部分 Line 类比编辑 Line 代码库更有意义。


2
如果你准备更改数据库,为什么不在数据库中创建一个计算字段并将其设置为storegenerated呢? - alun
我正在研究计算字段,但是我找不到完美的解决方案。计算列需要是相关表中另一个计算列的总和+任何额外费用。此外,我不认为有一种方法可以在EF数据库设计器中指定公式。但是,如果有人发布了一个很酷的解决方案,我不会忽视它 ;) - Charles

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