5

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

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();
}

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

4

3 回答 3

5

如何制定 IQueryable 来查询递归数据库表?我希望它是一个 IQueryable,因此只有 1 个查询进入数据库。

我认为实体框架目前无法使用单个翻译查询遍历分层表。原因是您需要实现循环或递归,据我所知,两者都不能转换为 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
于 2012-11-27T12:15:30.510 回答
3

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

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;
}
于 2012-11-17T11:40:18.067 回答
1

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

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”实体,但这太令人困惑了,我会重新设计这个:要么放置一个唯一的约束,以便每个实体只能使用一次,要么将完整路径存储在“代码”列中。

于 2012-11-27T01:53:51.527 回答