0

Context

I have a simple link aggregator ASP.NET Core app built using Razor Pages.

enter image description here

The details page for each link shows the comments:

Users can comment on a link and reply to comments.

Models

Here's the Link class:

    public class Link
    {
        public int Id { get; set; }
        public string UserId { get; set; }

        [DataType(DataType.Url)]
        public string Url { get; set; }

        public string Title { get; set; }

        [Display(Name = "Date")]
        [DataType(DataType.Date)]
        public DateTime DateTime { get; set; }

        [ForeignKey("UserId")]
        public IdentityUser User { get; set; }

        public List<Vote> Votes { get; set; }

        public int Score() => Votes.Sum(vote => vote.Score);

        public Vote UserVote(string userId) => 
            Votes.FirstOrDefault(vote => vote.UserId == userId);

        public int UserScore(string userId)
        {
            var vote = UserVote(userId);

            return vote == null ? 0 : vote.Score;
        }

        public async Task Vote(int score, string voterUserId)
        {
            var vote = UserVote(voterUserId);

            if (vote == null)
            {
                vote = new Vote()
                {
                    UserId = voterUserId,
                    LinkId = Id,
                    Score = score,
                    DateTime = DateTime.Now
                };

                Votes.Add(vote);
            }
            else
            {
                vote.Score = vote.Score == score ? 0 : score;
            }
        }
              
        public List<Comment> Comments { get; set; }

        public async Task AddComment(string text, string commenterUserId)
        {
            var comment = new Comment()
            {
                UserId = commenterUserId,
                LinkId = Id,
                Text = text,
                DateTime = DateTime.Now
            };

            Comments.Add(comment);
        }
    }

Here's the Comment class:

    public class Comment
    {
        public int Id { get; set; }

        public int? LinkId { get; set; }
        //[ForeignKey("LinkId")]
        public Link Link { get; set; }

        public int? ParentCommentId { get; set; }
        //[ForeignKey("ParentCommentId")]
        public Comment ParentComment { get; set; }

        public List<Comment> Comments { get; set; }

        public string Text { get; set; }
        public DateTime DateTime { get; set; }

        public string UserId { get; set; }
        [ForeignKey("UserId")]
        public IdentityUser User { get; set; }

        public List<CommentVote> Votes { get; set; }

        public int Score() =>
            Votes
                .Where(vote => vote.CommentId == Id)
                .Sum(vote => vote.Score);

        public CommentVote UserVote(string userId) =>
            Votes.FirstOrDefault(vote => vote.UserId == userId);

        public int UserScore(string userId)
        {
            var vote = UserVote(userId);

            return vote == null ? 0 : vote.Score;
        }

        public async Task Vote(int score, string voterUserId)
        {
            var vote = UserVote(voterUserId);

            if (vote == null)
            {
                vote = new CommentVote()
                {
                    UserId = voterUserId,
                    CommentId = Id,
                    Score = score,
                    DateTime = DateTime.Now
                };

                Votes.Add(vote);
            }
            else
            {
                vote.Score = vote.Score == score ? 0 : score;
            }
        }

        public async Task AddComment(string text, string commenterUserId)
        {
            var comment = new Comment()
            { 
                UserId = commenterUserId,
                ParentCommentId = Id,
                Text = text,
                DateTime = DateTime.Now
            };

            Comments.Add(comment);
        }
    }

Details Razor page

Here's the OnGetAsync method for the Details page.

public async Task<IActionResult> OnGetAsync(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    Link = await _context.Link
        .Include(link => link.User)
        .Include(link => link.Votes)
        .Include(link => link.Comments)
        .FirstOrDefaultAsync(m => m.Id == id);

    void load_comments(List<Comment> comments)
    {
        foreach (var comment in comments)
        {
            _context.Entry(comment).Reference(comment => comment.User).Load();
            
            _context.Entry(comment).Collection(comment => comment.Comments).Load();

            _context.Entry(comment).Collection(comment => comment.Votes).Load();

            load_comments(comment.Comments);
        }
    }

    load_comments(Link.Comments);

    if (Link == null)
    {
        return NotFound();
    }
    return Page();
}

As you can see, I use Include with various navigation properties of the Link.

When a user adds a comment to a Link, the LinkId of the Comment has a value. When a user adds a comment to a Comment, the ParentCommentId of the Comment has a value.

Since comments can have comments, I use the recursive internal function load_comments to retrieve the comments tree via explicit loading.

Suggested approaches

A Google search for ef core include recursive leads to various approaches to loading recursive entities.

  • This answer on stackoverflow recommends a recursive function approach, somewhat similar to what I've shown above.

  • However, this popular answer by an experienced user suggests that the whole tree can be retrieved via Include (eager loading), without needing to use explicit loading.

The question

Is there a way to get the comments for a Link using eager loading instead of explicit loading? It seems like that would be a simpler implementation that the explicit recursive approach.

The second answer above suggests that it should work. However, implementing that suggestion (and some variations on it) leads to runtime errors when loading the comments page.

Full project

The full project is available on github: LinkAggregatorComments. This is just a snapshot of the project, meant for questions like this.

Links to some of the parts discussed above:

dharmatech
  • 8,979
  • 8
  • 42
  • 88
  • 1
    The key point in my answer you are referring to is the word *whole*, i.e. the complete table without any filtering. Since you want to filter by `Link`, it does not apply. Which means you are back in the lack of hierarchical (CTE) support in EF Core. Btw, if you have stored `LinkId` for child comments - yes, I know it is redundant and comes from the parent, but it would have given you a simple non hierarchical filter criteria for all link comments, thus the technique with simple `Include` would have worked. – Ivan Stoev Apr 24 '21 at 08:29
  • 1
    Thank you for your comments @IvanStoev. I've posted my reply in a [gist](https://gist.github.com/dharmatech/57e6bf92828e1ce7c9f70226fbc32f61) as it's too long for the comments here. Just want to make sure I'm understanding your suggestion. – dharmatech Apr 24 '21 at 09:25
  • 1
    Yes, you got it right. It's true that with such approach the `Link.Comments` will contain all the comments for the `Link` - similar to `DbSet` which contains all comments, not just the top ones. But you can always easily filter out the top ones with `Link.Comments.Where(c => c,ParentCommentId == null)` where needed. And make sure when saving existing `Link` to not delete the comments missing in the `Comments` collection. The solution has its cons - while it solves the load problem, it complicates some of the other operations, including serialization/deserialization... – Ivan Stoev Apr 24 '21 at 09:45
  • 1
    ...So it depends how much control do you have on all CRUD operations with the involved entities. – Ivan Stoev Apr 24 '21 at 09:46
  • 1
    @IvanStoev, thank you for confirming. I see what you mean that there are pros/cons to both approaches. – dharmatech Apr 24 '21 at 10:40
  • 1
    Yes, unfortunately. That's why I'm just commenting and not posting an "answer". To recap shortly, there is no good out-of-the-box solution of the problem. You can stick with the current approach (thought it could be optimized to execute much less SQL queries), or if you are targeting a single database and it supports recursive queries, use a combination of raw SQL (probably accessing database view) and composing EF Core/LINQ query over it, or look at some 3rd party extension like [linq2db.EntityFrameworkCore](https://github.com/linq2db/linq2db.EntityFrameworkCore) which along with the many ... – Ivan Stoev Apr 24 '21 at 11:20
  • 1
    other improvements adds support for [Use CTE in LINQ queries](https://linq2db.github.io/articles/sql/CTE.html). I haven't tried it, but one of its maintainers - [Svyatoslav Danyliv](https://stackoverflow.com/users/10646316/svyatoslav-danyliv) is actively posting recently on SO and looks like they know what are doing. – Ivan Stoev Apr 24 '21 at 11:26

1 Answers1

0

If all the child comments refer to the link, then you don't need the load_comment part at all. EF fixes the parent-child relations as they are loaded into the context, and Include(link => link.Comments) loads all of them.

Load comments' votes and users with ThenInclude.

cgrevil
  • 23
  • 1
  • 5