LINQ to Entities中是否可以进行递归查询?

7

这是我的第一个问题,很抱歉我的语言表达能力不够好。

我有一个像这个模型的表格:

 public class Menu
 {
      [Key]
      public int ID {get;set;}
      public int ParentID {get;set;}
      public string MenuName {get;set;}
      public int OrderNo {get;set;}
      public bool isDisplayInMenu {get;set;} // Menu or just for Access Authority
 }

还有很多关于菜单的行,类似这样:

ID     ParentID      MenuName          Order
---    ---------     -------------     ------
1      0             Main.1               1     >> if ParentID==0 is Root
2      1             Sub.1.1              1
3      2             Sub.1.2              2
4      0             Main.2               2
5      4             Sub.2.1              1
6      4             Sub.2.2              2

我需要准备一个菜单树的二级分类。
public class MyMenu:Menu
{
    public List<MyMenu> Childs { get;set;}
}

我需要一个Linq查询来获取类似这样的结果:
var result = (...linq..).ToList<MyMenu>();

我正在使用递归函数获取子菜单,但是这需要太长的时间才能得到结果。

有什么办法可以在一次查询中获取所有菜单树的句子吗?

更新:

我想将主菜单存储在一个表中。这个表将用于用户访问权限控制。有些行将显示在菜单中,有些行将仅用于获取访问权限。

在这种情况下,我需要多次获取表格树。表格树将被创建为过滤后的用户权限。当获取树时,将其存储在会话中。但是,许多会话意味着更多的RAM占用。如果有快速获取菜单树的方法,则不需要存储在会话中。


LINQ查询太宽泛了。你是在谈论EF吗?请相应地更新帖子和标签。 - Ivan Stoev
菜单实体应该有所谓的“导航属性”指向其父级和子级。我认为ParentID成员应该是可空的(因为必须有一个根菜单)。看一下这个链接:https://msdn.microsoft.com/zh-cn/library/jj713564(v=vs.113).aspx - Efrain
3
LINQ to Entities不支持递归查询。如果您需要加载整个树并可以设置FK关系(这需要ParentIDint?,且根项目为null而不是0),则可以使用类似于此链接中的内容。否则,您必须执行多个查询。主要问题是您是否需要整个树还是基于某些条件的子树。因此,请澄清您的要求并展示您已经完成的工作。 - Ivan Stoev
谢谢你的FK关系建议。我以前从未使用过关系。我会查看你提供的关于可空整数parentIDs的其他答案链接。 - firstEtap
4个回答

6

如果您需要遍历整个树,应该使用存储过程。Entity Framework 对于递归关系特别不适用。您要么需要为每个级别发出 N+1 个查询,要么预先加载一组定义的级别。例如,.Include("Childs.Childs.Childs") 将加载三个级别。然而,这将创建一个庞大的查询,并且您仍然需要为任何未在开头包含的其他级别发出 N+1 个查询。

在 SQL 中,您可以使用 WITH 递归地遍历表格,它比 Entity Framework 可以做的任何事情都要快得多。但是,您的结果将被展平,而不是从 Entity Framework 返回的对象图形。例如:

DECLARE @Pad INT = (
    SELECT MAX([Length])
    FROM (
        SELECT LEN([Order]) AS [Length] FROM [dbo].[Menus]
    ) x
);

WITH Tree ([Id], [ParentId], [Name], [Hierarchy]) AS
(
    SELECT
        [ID],
        [ParentID],
        [MenuName],
        REPLICATE('0', @Pad - LEN([Order])) + CAST([Order] AS NVARCHAR(MAX))
    FROM [dbo].[Menus]
    WHERE [ParentID] = 0 -- root
    UNION ALL
        SELECT
            Children.[ID],
            Children.[ParentID],
            Children.[MenuName],
            Parent.[Hierarchy] + '.' + REPLICATE('0', @Pad - LEN(Children.[Order])) + CAST(Children.[Order] AS NVARCHAR(MAX)) AS [Hierarchy]
        FROM [dbo].[Menus] Children
        INNER JOIN Tree AS Parent
            ON Parent.[ID] = Children.[ParentID]
)
SELECT
    [ID],
    [ParentID],
    [MenuName]
FROM Tree
ORDER BY [Hierarchy]

那看起来比实际复杂得多。为了确保菜单中的项目按父级和它们在该父级树中的位置正确排序,我们需要创建一个层次结构表示顺序以进行排序。我在这里创建一个字符串形式为1.1.1,其中基本上每个项目的顺序都附加到父级层次结构字符串的末尾。我还使用REPLICATE对每个级别的顺序进行左填充,以便您不会遇到数字的字符串排序常见问题,例如102之前,因为它以1开头。声明@Pad只是获取我需要填充的最大长度,基于表中最高顺序号。例如,如果最大顺序号是123之类的东西,则@Pad的值将为3,以便小于123的订单仍为三个字符(即001)。
一旦你理解了所有这些,SQL的其余部分就非常简单了。您只需选择所有根项,然后通过遍历树并联接每个新级别的所有子项来合并所有项。最后,您从此树中选择所需的信息,按照我们创建的层次结构排序字符串排序。
至少对于我的树,此查询速度还可以接受,但如果复杂性扩展或需要处理大量菜单项,则可能会稍微慢一些。即使使用此查询,做一些缓存树的工作也不是坏主意。就个人而言,对于诸如站点导航之类的东西,我建议结合OutputCache使用子操作。您在布局中调用子操作以显示导航,它将运行操作以获取菜单或检索已从缓存中创建的HTML(如果存在)。如果菜单针对个别用户,则只需确保自定义字符串有所变化,并在其中考虑用户ID或其他内容。您还可以仅将查询结果存储在内存缓存中,但是您也可以减少生成HTML的成本。但是,应避免将其存储在会话中。

1
这完全是错误的。EF 不会递归查询,即使它这样做了,也需要类似我发布的查询,即使用 WITH。单个连接不会加载所有级别。 - Chris Pratt
2
没关系。我非常确定自己在说什么,因为我不使用懒加载(没有virtual属性,ProxyCreationEnabledLazyLoadingEnabled都设置为false)。它是急切加载,借助上下文跟踪服务和导航属性修复功能完成的。所有这些都适用于最新的EF6.1.3(在早期版本中可能会有所不同),也适用于当前完全没有懒加载的EF Core。 - Ivan Stoev
1
啊,我想我已经弄清楚混淆了。看起来EF Core已经添加了对hierarchyid的支持。这个SQL Server的特性允许轻松地建立关系层次结构,但以前在EF中从未得到支持。如果你正在使用EF Core,那么你所说的可能是正确的,但在EF6或更低版本中绝对不会起作用。 - Chris Pratt
亲爱的前辈们,你们所经历的事情是我梦寐以求的 :) 非常感谢你们俩和你们的分享。这些对我启发很大! - firstEtap
@ChrisPratt 感谢我们的讨论,我意识到根本不需要任何 Include - Ivan Stoev
显示剩余3条评论

6

LINQ to Entities不支持递归查询。

然而,加载存储在数据库表中的整个树非常容易和高效。关于Entity Framework早期版本的一些谣言,现在我们来揭开它们的神秘面纱。

你只需要创建一个合适的模型和外键关系:

模型:

public class Menu
{
    public int ID { get; set; }
    public int? ParentID { get; set; }
    public string MenuName { get; set; }
    public int OrderNo { get; set; }
    public bool isDisplayInMenu { get; set; }

    public ICollection<Menu> Children { get; set; }
}

流畅的配置:

modelBuilder.Entity<Menu>()
    .HasMany(e => e.Children)
    .WithOptional() // EF6
    .WithOne() // EF Core
    .HasForeignKey(e => e.ParentID);

重要变化是为了建立这样的关系,ParentID 必须可为空,并且根项目应使用 null 而不是 0
现在,有了模型,加载整个树就像这样简单:
var tree = db.Menu.AsEnumerable().Where(e => e.ParentID == null).ToList();

使用AsEnumerable()方法可以确保在执行查询时,整个表将以一个简单的非递归SELECT SQL语句的形式存储在内存中。然后我们只需过滤出根项目即可。

就这样。最终,我们将得到一个包含根节点及其子孙节点等的列表!

它是如何工作的?不需要使用懒加载、贪婪加载或显式加载。整个魔法都是由DbContext跟踪和导航属性修复系统提供的。


4
很好。我想强调这适用于相对较小的表格。我认为这些谬论与大型层次结构有关,或者与只查询某些特定级别的层次结构有关。使用LINQ to E实现这一点永远不会像这样优雅。尽管如此,人们仍然在寻求一行语句来完成它。 - Gert Arnold

1

我会尝试类似这样的东西。

这个查询将从数据库中获取所有菜单记录,并创建一个字典,以ParentId作为键,特定父ID的所有菜单作为值。

// if you're pulling the data from database with EF
var map = (from menu in ctx.Menus.AsNoTracking()
           group by menu.ParentId into g
           select g).ToDictionary(x => x.Key, x => x.ToList());

现在我们可以很容易地迭代parentIds并创建MyMenu实例。
var menusWithChildren = new List<MyMenu>()
foreach(var parentId in map.Keys)
{
   var menuWithChildren = new MyMenu { ... }
   menuWithChildren.AddRange(map[parentId]);
}

现在你有一个带有关联的列表。这样,您将通过引用拥有子项和父项的关联(不会在不同嵌套级别之间重复引用)。但是我想知道如果需要知道根节点,您如何定义它们?我不知道这是否适合您。

具有父ID等于0的元素是根元素。我的函数如下:public List<MyMenu> getMenu(List<MyMenu> allMenuElements, int ParentID),我首先调用此函数 getMenu(allMenuElements, 0)。在该函数中,对于每个父元素,再次调用相同的函数以获取其子元素。 - firstEtap
我很好奇,是否有办法在查询中获取列表? - firstEtap
还有,谢谢你为我节省的时间,也感谢你关于"AsNoTracking"提示的帮助。我学到了新的东西 :) - firstEtap
对于你的EF模型的当前结构,我不太确定。但是,如果你想寻找替代方案,请查看@ivanstoev在你的帖子中的评论。 - vasil oreshenski

0
public class Menu
{
    public int ID { get; set; }
    public int? ParentID { get; set; }
    public string MenuName { get; set; }
    public int OrderNo { get; set; }
    public bool isDisplayInMenu { get; set; }
    public Menu Parent { get; set; }
    public ICollection<Menu> Children { get; set; }
}

菜单配置:

builder.HasMany(z => z.Children).WithOne(z => z.Parent).HasForeignKey(z => z.ParentId).OnDelete(DeleteBehavior.Cascade);

菜单服务:

public Task<List<Menu>> GetListByChildren()
{
   return _dbSet.AsNoTracking().Include(z => z.Children).Where(z => z.ParentId == null).ToListAsync();
 }

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