EF Core + Automapper ProjectTo 中的递归 CTE

Recursive CTE in EF Core + Automapper ProjectTo

在我的项目中我有类型 CommentCommentDto:

public class Comment
{
    public Guid CommentId { get; set; }
    public string Content { get; set; }

    public Guid PostId { get; set; }
    public virtual Post Post { get; set; }

    public Guid? ParentCommentId { get; set; }
    public virtual Comment ParentComment { get; set; }
    public virtual ICollection<Comment> InverseParentComment { get; set; }
}
class CommentDto
{
    public Guid CommentId { get; set; }
    public string Content { get; set; }
    public Guid? ParentCommentId { get; set; }
    public ICollection<CommentDto> InverseParentComment { get; set; }
}

Comment 将映射到 CommentDto。这是 configuration:

cfg.CreateMap<Comment, CommentDto>();

我有以下递归 CTE,封装在 table 值函数中:

FUNCTION [dbo].[fn_PostCommentHierarchy] (@postId UNIQUEIDENTIFIER)
RETURNS TABLE
AS
RETURN
(
    WITH cte AS
    (
        SELECT CommentId, Content, PostId, ParentCommentId 
        FROM dbo.Comment
        WHERE ParentCommentId IS NULL and PostId = @postId

        UNION ALL

        SELECT child.CommentId, child.Content, child.PostId, child.ParentCommentId
        FROM dbo.Comment child
        INNER JOIN cte parent
        ON parent.CommentId = child.ParentCommentId
        WHERE parent.PostId = @postId
    )

    SELECT * FROM cte
);

此函数允许获取给定 post 的评论层次结构(它采用 post 的 ID)。

数据库中存有评论:

为了更易读,我按层次顺序表示(仅Content 属性):

使用 EF Core

调用函数 fn_PostCommentHierarchy
List<Comment> commentHierarchy = await _context.Comment
    .FromSqlInterpolated($"SELECT CommentId, Content, PostId, ParentCommentId FROM dbo.fn_PostCommentHierarchy('post-id-here')")
    .ToListAsync();

EF Core 将以下 SQL-查询发送到 SQL-服务器:
SELECT CommentId, Content, PostId, ParentCommentId FROM dbo.fn_PostCommentHierarchy('post-id-here')

上面的代码按预期工作(JSON-格式用于提高可读性):

[
<b>{
    "commentId": "be02742a-9170-4335-afe7-3c7c22684424",
    "content": "Hello World!",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": null,
    "parentComment": null,
    "commentRates": [],
    "inverseParentComment": [
    {
        "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "content": "Are you a programmer?",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
        "commentRates": [],
        "inverseParentComment": [
        {
            "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
            "content": "Sure",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
            "commentRates": [],
            "inverseParentComment": []
        },
        {
            "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
            "content": "What?",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
            "commentRates": [],
            "inverseParentComment": []
        }
        ]
    }
    ]
},
{
    "commentId": "cfe126b3-4601-4432-8c87-445c1362a225",
    "content": "I wanna go to Mars too!",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": null,
    "parentComment": null,
    "commentRates": [],
    "inverseParentComment": [
    {
        "commentId": "ab6d6b49-d772-48cd-9477-8d40f133c37a",
        "content": "See you on the Moon :)",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "cfe126b3-4601-4432-8c87-445c1362a225",
        "commentRates": [],
        "inverseParentComment": []
    }
    ]
}</b>,
{
    "commentId": "ab6d6b49-d772-48cd-9477-8d40f133c37a",
    "content": "See you on the Moon :)",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": "cfe126b3-4601-4432-8c87-445c1362a225",
    "parentComment": {
        "commentId": "cfe126b3-4601-4432-8c87-445c1362a225",
        "content": "I wanna go to Mars too!",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": null,
        "parentComment": null,
        "commentRates": [],
        "inverseParentComment": []
    },
    "commentRates": [],
    "inverseParentComment": []
},
{
    "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "content": "Are you a programmer?",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
    "parentComment": {
        "commentId": "be02742a-9170-4335-afe7-3c7c22684424",
        "content": "Hello World!",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": null,
        "parentComment": null,
        "commentRates": [],
        "inverseParentComment": []
    },
    "commentRates": [],
    "inverseParentComment": [
    {
        "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
        "content": "Sure",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "commentRates": [],
        "inverseParentComment": []
    },
    {
        "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
        "content": "What?",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "commentRates": [],
        "inverseParentComment": []
    }
    ]
},
{
    "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
    "content": "Sure",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "parentComment": {
        "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "content": "Are you a programmer?",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
        "parentComment": {
            "commentId": "be02742a-9170-4335-afe7-3c7c22684424",
            "content": "Hello World!",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": null,
            "parentComment": null,
            "commentRates": [],
            "inverseParentComment": []
        },
        "commentRates": [],
        "inverseParentComment": [
        {
            "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
            "content": "What?",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
            "commentRates": [],
            "inverseParentComment": []
        }
        ]
    },
    "commentRates": [],
    "inverseParentComment": []
},
{
    "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
    "content": "What?",
    "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
    "post": null,
    "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "parentComment": {
        "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "content": "Are you a programmer?",
        "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
        "post": null,
        "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
        "parentComment": {
            "commentId": "be02742a-9170-4335-afe7-3c7c22684424",
            "content": "Hello World!",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": null,
            "parentComment": null,
            "commentRates": [],
            "inverseParentComment": []
        },
        "commentRates": [],
        "inverseParentComment": [
        {
            "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
            "content": "Sure",
            "postId": "69f3ca3a-66fc-4142-873d-01e950d83adf",
            "post": null,
            "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
            "commentRates": [],
            "inverseParentComment": []
        }
        ]
    },
    "commentRates": [],
    "inverseParentComment": []
}]</pre>

注意:我做了大胆的评论,没有父评论(根评论)。

Comment 映射到 CommentDto

上面的代码适用于实体类型 Comment,但我想将其映射到 CommentDto。因此,让我们为此目的使用 ProjectTo

List<CommentDto> commentHierarchy = await _context.Comment
    .FromSqlInterpolated($"SELECT CommentId, Content, PostId, ParentCommentId FROM dbo.fn_PostCommentHierarchy('post-id-here')")
    .ProjectTo<CommentDto>(_mapper.ConfigurationProvider)
    .ToListAsync();

注意_mapperIMapper.

类型的对象

我想,结果应该和我之前用ProjectTo得到的结果差不多。但它看起来像:

[<b>
{
    "commentId": "be02742a-9170-4335-afe7-3c7c22684424",
    "content": "Hello World!",
    "parentCommentId": null,
    "inverseParentComment": [
    {
        "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "content": "Are you a programmer?",
        "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
        "inverseParentComment": null
    }
    ]
},
{
    "commentId": "cfe126b3-4601-4432-8c87-445c1362a225",
    "content": "I wanna go to Mars too!",
    "parentCommentId": null,
    "inverseParentComment": [
    {
        "commentId": "ab6d6b49-d772-48cd-9477-8d40f133c37a",
        "content": "See you on the Moon :)",
        "parentCommentId": "cfe126b3-4601-4432-8c87-445c1362a225",
        "inverseParentComment": null
    }
    ]
}</b>,
{
    "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
    "content": "Sure",
    "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "inverseParentComment": []
},
{
    "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
    "content": "What?",
    "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "inverseParentComment": []
},
{
    "commentId": "59656765-d1ed-4648-8696-7d576ab7419f",
    "content": "Are you a programmer?",
    "parentCommentId": "be02742a-9170-4335-afe7-3c7c22684424",
    "inverseParentComment": [
    {
        "commentId": "0bb77a43-c7bb-482f-9bf8-55c4050974da",
        "content": "Sure",
        "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "inverseParentComment": null
    },
    {
        "commentId": "b8d61cfd-d274-4dae-a2be-72e08cfa9066",
        "content": "What?",
        "parentCommentId": "59656765-d1ed-4648-8696-7d576ab7419f",
        "inverseParentComment": null
    }
    ]
},
{
    "commentId": "ab6d6b49-d772-48cd-9477-8d40f133c37a",
    "content": "See you on the Moon :)",
    "parentCommentId": "cfe126b3-4601-4432-8c87-445c1362a225",
    "inverseParentComment": []
}
]

注意:我做了大胆的评论,没有父评论(根评论)。
比较使用 ProjectTo 之前和之后的结果。为什么它们不同?

对于上面的代码,EF Core 将以下 SQL-查询发送到 SQL-服务器:

SELECT [c].[CommentId], [c].[Content], [c].[ParentCommentId], [c0].[CommentId], [c0].[Content], [c0].[ParentCommentId]
FROM (
    SELECT CommentId, Content, PostId, ParentCommentId FROM dbo.fn_PostCommentHierarchy('69f3ca3a-66fc-4142-873d-01e950d83adf')
) AS [c]
LEFT JOIN [Comment] AS [c0] ON [c].[CommentId] = [c0].[ParentCommentId]
ORDER BY [c].[CommentId], [c0].[CommentId]

问题

为什么使用ProjectTo前的结果和使用ProjectTo后的结果不一样? 如何解决这个问题?

更新 1

根据 Svyatoslav Danyliv 的说法:

Recursive CTE returns flat list, then you have to build hierarchy again.

但为什么在这种情况下我应该使用递归 CTE?
以下解决方案的工作方式相同:

List<CommentDto> commentFlatList = await _context.Comment
    .Where(c => c.PostId == Guid.Parse("post-id-here"))
    .ProjectTo<CommentDto>(_mapper.ConfigurationProvider)
    .ToListAsync();
                
Dictionary<Guid, CommentDto> commentDictionary = commentFlatList
    .ToDictionary(c => c.CommentId);

foreach (var comment in commentFlatList)
{
    if (comment.ParentCommentId == null)
    {
        continue;
    }

    if (commentDictionary.TryGetValue((Guid) comment.ParentCommentId, out CommentDto parent))
    {
        parent.Children.Add(comment);
    }
}

List<CommentDto> commentHierarchy = commentFlatList.Where(c => c.ParentCommentId == null);

注意:我用Dictionary代替了Lookupsee this example),但并没有改变思路

更新 2

让我们看一下更新 1 中的代码:

List<CommentDto> commentFlatList = await _context.Comment
        .Where(c => c.PostId == Guid.Parse("post-id-here"))
        .ProjectTo<CommentDto>(_mapper.ConfigurationProvider)
        .ToListAsync();

它将被 EF Core 翻译成以下内容:

exec sp_executesql N'SELECT [c].[CommentId], [c].[Content], [c].[ParentCommentId]
FROM [Comment] AS [c]
WHERE [c].[PostId] = @__request_PostId_0',N'@__request_PostId_0 uniqueidentifier',@__request_PostId_0='post-id-here'

递归 CTE returns 平面列表,然后你必须重新构建层次结构。

var commentHierarchy = await _context.Comment
    .FromSqlInterpolated($"SELECT CommentId, Content, PostId, ParentCommentId FROM dbo.fn_PostCommentHierarchy('post-id-here')")
    .ProjectTo<CommentDto>(_mapper.ConfigurationProvider)
    .ToListAsync();

var lookup = commentHierarchy.ToLookup(x => x.commentId);

foreach (var c in commentHierarchy)
{
    if (lookup.Contains(c.commentId))
        c.inverseParentComment.AddRange(lookup.Item[c.commentId]);
}

var result = commentHierarchy.Where(c => c.parentCommentId == null);