如何使用Entity Framework进行递归加载?

27

我在数据库中有一棵树形结构,其中包含TreeNodes表。该表具有nodeId、parentId和parameterId。在EF中,结构类似于TreeNode.Children,其中每个子节点都是一个TreeNode...

我还有一个包含id、name和rootNodeId的Tree表。

最终,我想将树加载到TreeView中,但我无法想出如何一次性加载整个树。我尝试过:

var trees = from t in context.TreeSet.Include("Root").Include("Root.Children").Include("Root.Children.Parameter")
        .Include("Root.Children.Children")
                        where t.ID == id
                        select t;

这将获取前两代,但不包括更多的后代。 如何加载完整的家谱树,包括所有世代和其他数据?


1
如果这对您仍然是一个问题,我提供了另一个答案,它比冗长的数据库调用更好,并且能够填充整个层次结构,而无需指定无限包含。 - JoeBrockhaus
6个回答

20

最近我遇到了这个问题,后来在想出一个简单的方法后,我偶然发现了这个问题。我提供了对Craig答案的编辑,提供了第四种方法,但是决策者认为它应该是另一个答案。对我来说没关系 :)

我的原始问题/答案可以在这里找到。

只要您的表格中的项目都知道它们属于哪棵树(在您的情况下似乎是这样:t.ID),这个方法就有效。话虽如此,不清楚您真正使用的实体是什么,但即使您有多个实体,如果Children不是TreeSet,那么您必须在实体中有一个FK

基本上,只需不使用Include():

var query = from t in context.TreeSet
            where t.ID == id
            select t;

// if TreeSet.Children is a different entity:
var query = from c in context.TreeSetChildren
            // guessing the FK property TreeSetID
            where c.TreeSetID == id
            select c;

这将返回树中的所有项并将它们放在集合的根目录中。此时,您的结果集将如下所示:
-- Item1
   -- Item2
      -- Item3
-- Item4
   -- Item5
-- Item2
-- Item3
-- Item5

由于您可能希望从EF中只获取层次结构的实体,这不是您想要的,对吗?

..然后,排除在根级别存在的后代:

幸运的是,因为您的模型中有导航属性,子实体集合仍将被填充,正如您可以通过上面结果集的图示看到的那样。通过使用foreach()循环手动迭代结果集,并将这些根项目添加到一个new List<TreeSet>()中,现在您将拥有一个包含根元素和所有后代正确嵌套的列表。

如果您的树变得很大且性能是一个问题,您可以按升序排序返回集合,按ParentID(它是Nullable,对吧?)排序,以便所有根项都排在前面。像以前一样迭代并添加,但在遇到非空时中断循环。

var subset = query
     // execute the query against the DB
     .ToList()
     // filter out non-root-items
     .Where(x => !x.ParentId.HasValue);

现在subset看起来是这样的:

-- Item1
   -- Item2
      -- Item3
-- Item4
   -- Item5



关于Craig的解决方案:

  1. 你真的不想使用懒加载来处理这个问题!一个基于n+1查询的设计将会对性能产生严重影响。
  2. (嗯,公平地说,如果你要允许用户选择性地深入树形结构,那么它可能是合适的。只是不要使用懒加载获取所有内容!)

  3. 我从未尝试过嵌套集,也不建议通过修改EF配置来实现这一点,因为有一个更简单的解决方案。

  4. 另一个合理的建议是创建一个提供自链接的数据库视图,然后将该视图映射到一个中间连接/链接/m2m表。个人认为,这种解决方案比必要的还要复杂,但它可能有其用处。


JoeBrockhaus,我还没有测试你的代码,但有几件事情:1)那段代码只是简单地迭代并将数据填充为一个单一节点,而不是树节点。2)根节点永远不会添加。也许你应该尝试在 ICollectionable 中分配子对象。这样做,你就可以创建一个树。我不会投反对票,但请改进你的答案。 - Gabriel Andrés Brancolini
@GabrielAndrésBrancolini 原始问题对于模型缺少一些细节,但我的答案涵盖了我提到的两种可能情况。至于子项集合,这些将自动填充,因为实体的导航属性已定义(并且只要定义)。其中之一将是根节点。最终的变化在于逻辑:不要考虑查询父项并获取其递归子项,而是仅查询所有子项并在需要时独立获取父项。 - JoeBrockhaus
此外,关于负评的评论是因为在任何其他投票或评论之前,原始答案就已经被负评了,而这个答案至少有一些人认为是对问题更好的回答。 - JoeBrockhaus
这个答案对我很有用,尽管在使用Lazy load时需要额外的一步。 我正在使用带有Lazy load代理的 Entity Framework Core 3.1。 因此,即使树正在被填充,当访问“Children”时,代理也会触发额外的加载。 所以,解决方案是为每个返回的节点添加 dbContext.Entry(treeNode).Collection(n => n.Children).IsLoaded = true; - pmoleri

4
当你使用Include()时,你正在请求Entity Framework将你的查询翻译成SQL。因此请思考:如何编写一个返回任意深度树的SQL语句?
答案是:除非你在使用数据库服务器的具体层次结构特性(它们不是SQL标准,但一些服务器支持,例如SQL Server 2008,但它的Entity Framework提供程序不支持),否则你不会这样做。在SQL中处理任意深度的树状结构的通常方法是使用嵌套集模型而不是父ID模型。
因此,有三种解决该问题的方法:
  1. 使用嵌套集模型。这需要更改你的元数据。
  2. 使用SQL Server的层次结构特性,并让Entity Framework理解它们(比较棘手,但这个技巧可能有效)。同样,你需要更改你的元数据。
  3. 使用显式加载或EF 4的惰性加载代替急切加载。这将导致多个数据库查询而不是一个。

2
最后,我添加了一个递归调用,调用Children和其他引用对象上的Load函数。 - Avi Harush
FYI... "嵌套集模型"链接无法打开。InformationWeek说:“URL无效”。 - Mark Good

3

其他人的回答没有帮到我,所以我想发表我的回答。

我的数据库有些不同,基本上我的表格包含一个ID和一个ParentID。这个表是递归的。以下代码可以获取所有的子元素,并将它们嵌套到最终列表中。

public IEnumerable<Models.MCMessageCenterThread> GetAllMessageCenterThreads(int msgCtrId)
{
    var z = Db.MCMessageThreads.Where(t => t.ID == msgCtrId)
        .Select(t => new MCMessageCenterThread
        {
            Id = t.ID,
            ParentId = t.ParentID ?? 0,
            Title = t.Title,
            Body = t.Body
        }).ToList();

    foreach (var t in z)
    {
        t.Children = GetChildrenByParentId(t.Id);
    }

    return z;
}

private IEnumerable<MCMessageCenterThread> GetChildrenByParentId(int parentId)
{
    var children = new List<MCMessageCenterThread>();

    var threads = Db.MCMessageThreads.Where(x => x.ParentID == parentId);

    foreach (var t in threads)
    {
        var thread = new MCMessageCenterThread
        {
            Id = t.ID,
            ParentId = t.ParentID ?? 0,
            Title = t.Title,
            Body = t.Body,
            Children = GetChildrenByParentId(t.ID)
        };

        children.Add(thread);
    }

    return children;
}

为了完整起见,这是我的模型:

public class MCMessageCenterThread
{
    public int Id { get; set; }
    public int ParentId { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }

    public IEnumerable<MCMessageCenterThread> Children { get; set; }
}

今天这个对我很有帮助,谢谢! - Mayur Patel
这是递归加载方法。它将生成n个查询到数据库。代码存在一些问题:1. GetAllMessageCenterThreads返回一个列表,但只有一个项目(.Where(t=>t.ID == msgCtrId))。2. 您的ParentID属性应该是可空的(int? vs int),而且ParentId = t.ParentID ?? 0,总是会返回'0',因为ParentID不能为null(这不是Code-First吗?)。3. 看起来您的模型缺少一个属性:MessageCenterId。所有项目都应该有这个字段,这样您就可以通过该id获取所有项目。 - JoeBrockhaus
如果您在此情况下的目标是获取特定线程的所有回复(而不是特定博客的所有线程),那么您应该研究通用表达式(或CTE),这些表达式专门用于递归场景。在这种情况下,您将创建一个递归联合+自连接。请参见此答案:https://dev59.com/T2Ij5IYBdhLWcg3wwHtr#19915206 - JoeBrockhaus
这段代码是从一个数据库优先的示例中提取的;其次,如果你有更好的解决方案,也许你应该发帖分享一下,对吧?我猜抨击别人的可行解决方案总是更有趣 ;) - user1477388

1
我最近写了一些代码,它进行了N+1个选择来加载整个树形结构,其中N是源对象中最深路径的级数。
这是我的做法,假设有以下自引用类。
public class SomeEntity 
{
  public int Id { get; set; }
  public int? ParentId { get; set; }
  public string Name { get; set;
}

我编写了以下的DbSet助手。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;

namespace Microsoft.EntityFrameworkCore
{
    public static class DbSetExtensions
    {
        public static async Task<TEntity[]> FindRecursiveAsync<TEntity, TKey>(
            this DbSet<TEntity> source,
            Expression<Func<TEntity, bool>> rootSelector,
            Func<TEntity, TKey> getEntityKey,
            Func<TEntity, TKey> getChildKeyToParent)
            where TEntity: class
        {
            // Keeps a track of already processed, so as not to invoke
            // an infinte recursion
            var alreadyProcessed = new HashSet<TKey>();

            TEntity[] result = await source.Where(rootSelector).ToArrayAsync();

            TEntity[] currentRoots = result;
            while (currentRoots.Length > 0)
            {
                TKey[] currentParentKeys = currentRoots.Select(getEntityKey).Except(alreadyProcessed).ToArray();
                alreadyProcessed.AddRange(currentParentKeys);

                Expression<Func<TEntity, bool>> childPredicate = x => currentParentKeys.Contains(getChildKeyToParent(x));
                currentRoots = await source.Where(childPredicate).ToArrayAsync();
            }

            return result;
        }
    }
}

每当您需要加载整个树时,只需调用此方法,传入三个参数:
  1. 根对象的选择条件
  2. 如何获取对象的主键属性(SomeEntity.Id)
  3. 如何获取指向其父级的子级属性(SomeEntity.ParentId)
例如:
SomeEntity[] myEntities = await DataContext.SomeEntity.FindRecursiveAsync(
  rootSelector: x => x.Id = 42,
  getEntityKey: x => x.Id,
  getChildKeyToParent: x => x.ParentId).ToArrayAsync();
);

或者,如果您可以在表中添加RootId列,则对于每个非根条目,您可以将此列设置为树的根的ID。 然后,您可以使用单个选择获取所有内容。

DataContext.SomeEntity.Where(x => x.Id == rootId || x.RootId == rootId)


2
这对于非常低的RPS和小数据集可能还可以,但如果必须使用整个集合进行递归加载,您真的应该在数据库中使用CTE。 - JoeBrockhaus
这比 DataContext.SomeEntity.Where(x => x.Id == rootId || x.RootId == rootId) 更好吗? - Peter Morris
如果您有一个rootId,那么您就不需要在数据库中使用CTE。 CTE的价值在于执行所有递归迭代_在数据库中_,以便您不会产生连接和往返开销。 - JoeBrockhaus

0
这是一个老问题,但其他答案要么有n+1个数据库查询,要么它们的模型适合自下而上(从树干到叶子)的方法。在这种情况下,标签列表被加载为一棵树,一个标签可以有多个父级。我使用的方法只有两个数据库查询:第一个是获取所选文章的标签,然后是另一个贪婪地加载连接表。因此,这使用自上而下(从叶子到树干)的方法;如果您的连接表很大或者结果不能真正缓存以供重用,则整个事物的急切加载开始显示出这种方法的权衡。
首先,我初始化了两个HashSet:一个用于保存根节点(结果集),另一个用于保留每个已“命中”的节点的引用。
var roots = new HashSet<AncestralTagDto>(); //no parents
var allTags = new HashSet<AncestralTagDto>();

接下来,我会获取客户端请求的所有叶子,并将它们放入一个对象中,该对象持有一个子元素集合(但在此步骤后,该集合将保持为空)。
var startingTags = await _dataContext.ArticlesTags
        .Include(p => p.Tag.Parents)
        .Where(t => t.Article.CategoryId == categoryId)
        .GroupBy(t => t.Tag)
        .ToListAsync()
        .ContinueWith(resultTask => 
             resultTask.Result.Select(
                  grouping => new AncestralTagDto(
                        grouping.Key.Id, 
                        grouping.Key.Name)));

现在,让我们获取自连接表标签,并将其全部加载到内存中:
var tagRelations = await _dataContext.TagsTags.Include(p => p.ParentTag).ToListAsync();

现在,对于startingTags中的每个标签,将该标签添加到allTags集合中,然后递归地向下遍历树以获取祖先:
foreach (var tag in startingTags)
{
    allTags.Add(tag);
    GetParents(tag);
}
return roots;

最后,这是构建树的嵌套递归方法:
void GetParents(AncestralTagDto tag)
{
    var parents = tagRelations.Where(c => c.ChildTagId == tag.Id).Select(p => p.ParentTag);
    if (parents.Any()) //then it's not a root tag; keep climbing down
    {
        foreach (var parent in parents)
        {
            //have we already seen this parent tag before? If not, instantiate the dto.
            var parentDto = allTags.SingleOrDefault(i => i.Id == parent.Id);
            if (parentDto is null)
            {
                parentDto = new AncestralTagDto(parent.Id, parent.Name);
                allTags.Add(parentDto);
            }

            parentDto.Children.Add(tag);
            GetParents(parentDto);
        }
    }
    else //the tag is a root tag, and should be in the root collection. If it's not in there, add it.
    {
        //this block could be simplified to just roots.Add(tag), but it's left this way for other logic.
        var existingRoot = roots.SingleOrDefault(i => i.Equals(tag));
        if (existingRoot is null)
            roots.Add(tag);
    }
}

在底层,我依赖于 HashSet 的属性来防止重复。为此,你使用的中间对象(我在这里使用了 AncestralTagDto,它的 Children 集合也是一个 HashSet)必须根据你的用例适当地重写 Equals 和 GetHashCode 方法。

0

关于加载子对象的示例,我将举一个评论对象的例子。每个评论都可能有一个子评论。

private static void LoadComments(<yourObject> q, Context yourContext)
{
    if(null == q | null == yourContext)
    {
        return;
    }
    yourContext.Entry(q).Reference(x=> x.Comment).Load();
    Comment curComment = q.Comment;
    while(null != curComment)
    {
        curComment = LoadChildComment(curComment, yourContext);
    }
}

private static Comment LoadChildComment(Comment c, Context yourContext)
{
    if(null == c | null == yourContext)
    {
        return null;
    }
    yourContext.Entry(c).Reference(x=>x.ChildComment).Load();
    return c.ChildComment;
}

如果您需要处理一个包含自身集合的内容,您需要使用 Collection 而非 Reference,并进行相同类型的递归操作。至少在这种情况下,这是我采取的方法,因为我们正在处理 Entity 和 SQLite。

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