【问题标题】:How to formulate an IQueryable to query a recursive database table?如何制定 IQueryable 来查询递归数据库表?
【发布时间】:2012-11-17 10:53:54
【问题描述】:

我有一个这样的数据库表:

Entity
---------------------
ID        int      PK
ParentID  int      FK
Code      varchar
Text      text

ParentID 字段是一个外键,同一个表中的另一个记录(递归)。所以这个结构代表一棵树。

我正在尝试编写一种方法来查询此表并根据路径获取 1 个特定实体。路径将是表示实体和父实体的Code 属性的字符串。所以一个示例路径是"foo/bar/baz",这意味着一个特定的实体,其中Code == "baz"、父级Code == "bar"和父级Code == "foo"的父级。

我的尝试:

public Entity Single(string path)
{
 string[] pathParts = path.Split('/');
 string code = pathParts[pathParts.Length -1];

 if (pathParts.Length == 1)
  return dataContext.Entities.Single(e => e.Code == code && e.ParentID == 0);

 IQueryable<Entity> entities = dataContext.Entities.Where(e => e.Code == code);
 for (int i = pathParts.Length - 2; i >= 0; i--)
 {
  string parentCode = pathParts[i];
  entities = entities.Where(e => e.Entity1.Code == parentCode); // incorrect
 }

 return entities.Single();
}

我知道这是不正确的,因为 forloop 中的 Where 只是向当前实体而不是父实体添加了更多条件,但是我该如何纠正?换句话说,我希望 for 循环说“并且父代码必须是 x,并且该父代码的父代码必须是 y,并且该父代码的父代码必须是 z .... 等等”。除此之外,出于性能原因,我希望它是一个 IQueryable,因此只有 1 个查询进入数据库。

【问题讨论】:

  • 我认为你应该从根开始。找到根实体,然后从具有 parentId == rootId 的实体中找到具有指定代码的实体。然后递归地继续路径的下一部分。

标签: c# linq-to-sql iqueryable


【解决方案1】:

如何制定 IQueryable 来查询递归数据库表? 我希望它是一个 IQueryable 所以只有 1 个查询 到数据库。

我认为目前使用 Entity Framework 无法使用单个翻译查询遍历分层表。原因是您需要实现循环或递归,据我所知,两者都不能转换为 EF 对象存储查询。

更新

@Bazzz 和@Steven 让我开始思考,我不得不承认我完全错了:动态地为这些需求构建一个IQueryable 是可能的而且非常容易。

可以递归调用以下函数来构建查询:

public static IQueryable<TestTree> Traverse(this IQueryable<TestTree> source, IQueryable<TestTree> table, LinkedList<string> parts)
{
    var code = parts.First.Value;
    var query = source.SelectMany(r1 => table.Where(r2 => r2.Code == code && r2.ParentID == r1.ID), (r1, r2) => r2);
    if (parts.Count == 1)
    {
        return query;
    }
    parts.RemoveFirst();
    return query.Traverse(table, parts);
}

根查询是一种特殊情况;这是一个调用Traverse的工作示例:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var parts = new LinkedList<string>(path.Split('/'));
    var table = context.TestTrees;

    var code = parts.First.Value;
    var root = table.Where(r1 => r1.Code == code && !r1.ParentID.HasValue);
    parts.RemoveFirst();

    foreach (var q in root.Traverse(table, parts))
        Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

使用此生成的代码仅查询数据库一次:

exec sp_executesql N'SELECT 
[Extent3].[ID] AS [ID], 
[Extent3].[ParentID] AS [ParentID], 
[Extent3].[Code] AS [Code]
FROM   [dbo].[TestTree] AS [Extent1]
INNER JOIN [dbo].[TestTree] AS [Extent2] ON ([Extent2].[Code] = @p__linq__1) AND ([Extent2].[ParentID] = [Extent1].[ID])
INNER JOIN [dbo].[TestTree] AS [Extent3] ON ([Extent3].[Code] = @p__linq__2) AND ([Extent3].[ParentID] = [Extent2].[ID])
WHERE ([Extent1].[Code] = @p__linq__0) AND ([Extent1].[ParentID] IS NULL)',N'@p__linq__1 nvarchar(4000),@p__linq__2 nvarchar(4000),@p__linq__0 nvarchar(4000)',@p__linq__1=N'bar',@p__linq__2=N'baz',@p__linq__0=N'foo'

虽然我更喜欢原始查询的执行计划(见下文),但这种方法是有效的,也许有用。

更新结束

使用 IEnumerable

这个想法是一次性从表中获取相关数据,然后使用 LINQ to Objects 在应用程序中进行遍历。

这是一个从序列中获取节点的递归函数:

static TestTree GetNode(this IEnumerable<TestTree> table, string[] parts, int index, int? parentID)
{
    var q = table
        .Where(r => 
             r.Code == parts[index] && 
             (r.ParentID.HasValue ? r.ParentID == parentID : parentID == null))
        .Single();
    return index < parts.Length - 1 ? table.GetNode(parts, index + 1, q.ID) : q;
}

你可以这样使用:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var q = context.TestTrees.GetNode(path.Split('/'), 0, null);
    Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

这将为每个路径部分执行一个 DB 查询,因此如果您希望 DB 只被查询一次,请改用它:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var q = context.TestTrees
        .ToList()
        .GetNode(path.Split('/'), 0, null);
    Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

一个明显的优化是在遍历之前排除我们路径中不存在的代码:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var parts = path.Split('/');
    var q = context
        .TestTrees
        .Where(r => parts.Any(p => p == r.Code))
        .ToList()
        .GetNode(parts, 0, null);
    Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

除非您的大多数实体都有类似的代码,否则此查询应该足够快。但是,如果您绝对需要最佳性能,则可以使用原始查询。

SQL Server 原始查询

对于 SQL Server,基于 CTE 的查询可能是最好的:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var q = context.Database.SqlQuery<TestTree>(@"
        WITH Tree(ID, ParentID, Code, TreePath) AS
        (
            SELECT ID, ParentID, Code, CAST(Code AS nvarchar(512)) AS TreePath
            FROM dbo.TestTree
            WHERE ParentID IS NULL

            UNION ALL

            SELECT TestTree.ID, TestTree.ParentID, TestTree.Code, CAST(TreePath + '/' + TestTree.Code AS nvarchar(512))
            FROM dbo.TestTree
            INNER JOIN Tree ON Tree.ID = TestTree.ParentID
        )
        SELECT * FROM Tree WHERE TreePath = @path", new SqlParameter("path", path)).Single();
    Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

通过根节点限制数据很容易,并且在性能方面可能非常有用:

using (var context = new TestDBEntities())
{
    var path = "foo/bar/baz";
    var q = context.Database.SqlQuery<TestTree>(@"
        WITH Tree(ID, ParentID, Code, TreePath) AS
        (
            SELECT ID, ParentID, Code, CAST(Code AS nvarchar(512)) AS TreePath
            FROM dbo.TestTree
            WHERE ParentID IS NULL AND Code = @parentCode

            UNION ALL

            SELECT TestTree.ID, TestTree.ParentID, TestTree.Code, CAST(TreePath + '/' + TestTree.Code AS nvarchar(512))
            FROM dbo.TestTree
            INNER JOIN Tree ON Tree.ID = TestTree.ParentID
        )
        SELECT * FROM Tree WHERE TreePath = @path", 
            new SqlParameter("path", path),
            new SqlParameter("parentCode", path.Split('/')[0]))
            .Single();
    Console.WriteLine("{0} {1} {2}", q.ID, q.ParentID, q.Code);
}

脚注

所有这些都使用 .NET 4.5、EF 5、SQL Server 2012 进行了测试。数据设置脚本:

CREATE TABLE dbo.TestTree
(
    ID int not null IDENTITY PRIMARY KEY,
    ParentID int null REFERENCES dbo.TestTree (ID),
    Code nvarchar(100)
)
GO

INSERT dbo.TestTree (ParentID, Code) VALUES (null, 'foo')
INSERT dbo.TestTree (ParentID, Code) VALUES (1, 'bar')
INSERT dbo.TestTree (ParentID, Code) VALUES (2, 'baz')
INSERT dbo.TestTree (ParentID, Code) VALUES (null, 'bla')
INSERT dbo.TestTree (ParentID, Code) VALUES (1, 'blu')
INSERT dbo.TestTree (ParentID, Code) VALUES (2, 'blo')
INSERT dbo.TestTree (ParentID, Code) VALUES (null, 'baz')
INSERT dbo.TestTree (ParentID, Code) VALUES (1, 'foo')
INSERT dbo.TestTree (ParentID, Code) VALUES (2, 'bar')

我的测试中的所有示例都返回了 ID 为 3 的“baz”实体。假设该实体确实存在。错误处理超出了本文的范围。

更新

为了解决@Bazzz 的评论,带路径的数据如下所示。代码在级别上是唯一的,而不是全局唯一的。

ID   ParentID    Code      TreePath
---- ----------- --------- -------------------
1    NULL        foo       foo
4    NULL        bla       bla
7    NULL        baz       baz
2    1           bar       foo/bar
5    1           blu       foo/blu
8    1           foo       foo/foo
3    2           baz       foo/bar/baz
6    2           blo       foo/bar/blo
9    2           bar       foo/bar/bar

【讨论】:

  • 感谢您的回答,因为我认为您花了很多时间来测试所有这些方面。只有一件事我可能没有很好地解释。 Code 不是全局唯一的。它在树的级别上是独一无二的,但不是全局的。因此路径 foo/bar/baz 会产生 1 个实体,但是在数据库中查询代码为 baz 的实体可能会返回多个实体。路径bla/bar/baz 处可能有另一个实体,baz/foo/baz 处可能有另一个实体。所以没有从 Code 到 ID 的翻译。
  • @Bazzz 谢谢,我理解这方面,没有它可能根本没有问题。查看测试数据,在每个级别上放置几个 'foo'、'bar' 和 'baz' 就可以了。
  • 您说“您需要实现循环或递归,据我所知,两者都不能转换为 EF 对象存储查询。”但对我来说,史蒂文的回答似乎正是如此。我只是没有设法让它工作,但我认为它应该是可能的。他在 asp.net 端进行递归,并制作了许多 sql where/and 子句。你有什么理由觉得不可能这样做吗?我认为您当前的答案是他的一个有价值的替代方案,但我对他的方法(如果可行的话)更“着迷”,因为它更接近我最初的尝试。
  • @Bazzz 看来你是对的,这是可能的。看看更新的答案。我的真正意思是不可能在 single 查询中使用我实际尝试过的递归 lambdas 或扩展方法来做到这一点,但我想这并不重要。
  • 看来更新是我一直在寻找的实现。 SelectMany 是我知识上的差距。感谢您的回答和所有替代方案。 :)
【解决方案2】:

诀窍是反过来做,并建立以下查询:

from entity in dataContext.Entities
where entity.Code == "baz"
where entity.Parent.Code == "bar"
where entity.Parent.Parent.Code == "foo"
where entity.Parent.Parent.ParentID == 0
select entity;

有点幼稚(硬编码)的解决方案是这样的:

var pathParts = path.Split('/').ToList();

var entities = 
    from entity in dataContext.Entities 
    select entity;

pathParts.Reverse();

for (int index = 0; index < pathParts.Count+ index++)
{
    string pathPart = pathParts[index];

    switch (index)
    {
        case 0:
            entities = entities.Where(
                entity.Code == pathPart);
            break;
        case 1:
            entities = entities.Where(
                entity.Parent.Code == pathPart);
            break;
        case 2:
            entities = entities.Where(entity.Parent.Parent.Code == pathPart);
            break;
        case 3:
            entities = entities.Where(
                entity.Parent.Parent.Parent.Code == pathPart);
            break;
        default:
            throw new NotSupportedException();
    }
}

通过构建表达式树来动态执行此操作并非易事,但可以通过仔细查看 C# 编译器生成的内容来完成(例如,使用 ILDasm 或 Reflector)。这是一个例子:

private static Entity GetEntityByPath(DataContext dataContext, string path)
{
    List<string> pathParts = path.Split(new char[] { '/' }).ToList<string>();
    pathParts.Reverse();

    var entities =
        from entity in dataContext.Entities
        select entity;

    // Build up a template expression that will be used to create the real expressions with.
    Expression<Func<Entity, bool>> templateExpression = entity => entity.Code == "dummy";
    var equals = (BinaryExpression)templateExpression.Body;
    var property = (MemberExpression)equals.Left;

    ParameterExpression entityParameter = Expression.Parameter(typeof(Entity), "entity");

    for (int index = 0; index < pathParts.Count; index++)
    {
        string pathPart = pathParts[index];

        var entityFilterExpression =
            Expression.Lambda<Func<Entity, bool>>(
                Expression.Equal(
                    Expression.Property(
                        BuildParentPropertiesExpression(index, entityParameter),
                        (MethodInfo)property.Member),
                    Expression.Constant(pathPart),
                    equals.IsLiftedToNull,
                    equals.Method),
                templateExpression.Parameters);

        entities = entities.Where<Entity>(entityFilterExpression);

        // TODO: The entity.Parent.Parent.ParentID == 0 part is missing here.
    }

    return entities.Single<Entity>();
}

private static Expression BuildParentPropertiesExpression(int numberOfParents, ParameterExpression entityParameter)
{
    if (numberOfParents == 0)
    {
        return entityParameter;
    }

    var getParentMethod = typeof(Entity).GetProperty("Parent").GetGetMethod();

    var property = Expression.Property(entityParameter, getParentMethod);

    for (int count = 2; count <= numberOfParents; count++)
    {
        property = Expression.Property(property, getParentMethod);
    }

    return property;
}

【讨论】:

  • 感谢这个解决方案,它看起来很有趣。但是,在研究您的代码期间,我注意到您总是使用0 作为BuildParentPropertiesExpressionnumberOfParents 参数。也许您打算在这里提供index?另外,你为什么用你的方法static?您能否详细说明该决定的优势?
  • 我对你的解决方案有点挣扎:如上所述,我在index 中更改了0,然后我在(PropertyInfo)property.Member) 中更改了(MethodInfo)property.Member),因为我在那里遇到了演员错误,然后我在typeof(Entity).GetProperty("Entity1") 中更改了typeof(Entity).GetProperty("Parent"),因为我没有属性Parent,Linq2SQL 称之为Entity1。现在我得到“参数'实体'不在范围内。”在线return entities.Single&lt;Entity&gt;();。有什么线索吗?
  • 我没有运行这段代码。刚刚编译好了。只是试图告诉你前进的方向。例如,使用 Reflector 采用第一个(switch-case)解决方案并对其进行反编译会很好(也可以作为学习经验)。使用该信息创建动态解决方案。
  • +1 提出了很好的建议,尽管 Serge Belov 的回答更完整。
【解决方案3】:

你需要一个递归函数而不是你的循环。像这样的东西应该可以完成这项工作:

public EntityTable Single(string path)
{
    List<string> pathParts = path.Split('/').ToList();
    string code = pathParts.Last();

    var entities = dataContext.EntityTables.Where(e => e.Code == code);

    pathParts.RemoveAt(pathParts.Count - 1);
    return GetRecursively(entities, pathParts);
}

private EntityTable GetRecursively(IQueryable<EntityTable> entity, List<string> pathParts)
{
    if (!(entity == null || pathParts.Count == 0))
    {
        string code = pathParts.Last();

        if (pathParts.Count == 1)
        {
            return entity.Where(x => x.EntityTable1.Code == code && x.ParentId == x.Id).FirstOrDefault();
        }
        else
        {                    
            pathParts.RemoveAt(pathParts.Count - 1);

            return this.GetRecursively(entity.Where(x => x.EntityTable1.Code == code), pathParts);
        }
    }
    else
    {
        return null;
    }
}

如您所见,我只是返回最终的父节点。如果您想获取所有 EntityTable 对象的列表,那么我将使用递归方法返回找到的节点的 Id 列表,最后 - 在 Single(...) 方法中 - 运行一个简单的 LINQ 查询以获取使用此 ID 列表的 IQueryable 对象。

编辑: 我试图完成您的任务,但我认为存在一个根本问题:在某些情况下您无法识别单一路径。例如,您有两个路径“foo/bar/baz”和“foo/bar/baz/bak”,其中“baz”实体不同。如果您要寻找路径“foo/bar/baz”,那么您总是会找到两个匹配的路径(一个是四实体路径的一部分)。虽然你可以正确地得到你的“baz”实体,但这太令人困惑了,我会重新设计这个:要么设置一个唯一的约束,以便每个实体只能使用一次,要么将完整路径存储在“代码”列中。

【讨论】:

  • 听起来很有趣,但我确实没什么问题。 1)你介绍的对象EntityTable是什么?我的对象是Entity,我的数据上下文有一个属性Entities。实体代表一个数据库表。 2)return 指令是否不评估函数并向数据库发送调用(我不知道)?如果是这样,您现在可能会创建大量数据库调用。 3) 如果路径是“foo/bar/baz”,你返回的实体是Code == "foo" 还是Code == "baz"?我希望它返回带有Code == "baz" 的那个。我认为这段代码返回“foo”
  • 1) EntityTable 与代码中的实体相同 - 一个表 2) 它将进行单独的调用。我可能是错的,但在你的原始代码中也会发生同样的情况。 3)是的,它返回“foo”。抱歉,我没看懂任务。我将在当天晚些时候更新代码,但想法是将第三个参数传递给 GetRecursive(),如果是 Ids,它将是列表。在每次迭代中,GetRecursive 会将找到的记录的 Id 添加到列表中并返回列表。方法声明如下所示: private List GetRecursively(IQueryable entity, List pathParts, List Ids)
  • 然后只需在您的 single() 方法中运行一个查询,即可通过 Id 获取实体记录
  • 1) 理解。 2)不幸的是,这不是我想要的。我的示例只调用了 1 次数据库,因为 Where() 使用 Deferred Execution。我想递归地添加Where() 调用,所以只有1 IQueryable 在数据库级别执行。 3)我期待您的更新答案,但请考虑第 2 点。提前致谢。
  • @Bazzz 看起来这两种解决方案都无法正常工作。我编辑了我的答案来解释这一点。
猜你喜欢
  • 2015-11-01
  • 1970-01-01
  • 1970-01-01
  • 2018-08-20
  • 2021-10-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多