EF Core does not return more than 3 child objects

Viewed 1035

I have a folder structure in my application, where I recursively want to retrieve all children below a parent.

//Pseudocode
Context.Files.Include(p => p.Children);

In cases where my parent has 3 children, I retrieve an IEnumerable<Files> of Parent.Children.Children.Children.. However, when I have more than 3 children, It only returns the first child object, i.e. File.Children

The child property is written as follows in my object class:

public virtual ICollection<Files> Children { get; set; }

What am I overlooking here?

3 Answers

The issue is that .Include will not generate recursive SQL. It simply connects the foreign relation set for Children to your Files, which should be one nesting deep.

If you were to pull the entire set of Files, with .Include children, then what you will have is a set of edge pieces. In order to turn a set of edge pieces into a connected graph, you will need to manually compose that in your code.

Create a method which will iterate through each Files object, and if it is related to a Children set, then connect it into there. This will create your object graph and allow you to traverse in the manner you expect.

For added performance, create a top down GUID column for linking so that you can pull only the file tree you are interested in.

If you know the exact depth you are interested in, then you can manually compose the include. Alternatively, you could create a custom Expression Builder to do this dynamically but that is beyond the scope of this answer.

Context.Files.Include(
    p => p.Children.Select( 
        pc => pc.Children.Select( 
            pcc => pcc.Children
        )
    )
);

Here is a solution.

private static async Task<IReadOnlyList<File>> GetRootFilesAndChildren(IQueryable<File> source, int levels = 3)
{
    var dataSource = source.Where(x => x.ParentFileId == null); //root files

    if (levels == 0)
        return await dataSource.ToListAsync();

    var dataSourceIncludable = dataSource.Include(x => x.Children);
    for (var i = 1; i < levels; i++)
        dataSourceIncludable = dataSourceIncludable.ThenInclude(x => x.Children);

    var files = await dataSourceIncludable.ToListAsync();
    return files;
}

EDIT I forgot to include the models and configuration.

public class File
{
    public int FileId { get; set; }
    public string Name { get; set; }
    public int? ParentFileId { get; set; }
    public File ParentFile { get; set; }
    public virtual ICollection<File> Children { get; set; }
}

The DbContext for SQLite.

public class AppDbContext : DbContext
{
    public DbSet<File> Files { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=demo.db");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<File>(b =>
        {
            b.ToTable("Files");

            b.HasMany(x => x.Children)
                .WithOne(x => x.ParentFile)
                .IsRequired(false);
        });
    }
}

Depending on your scenario, there are multiple ways to accomplish what you want.


The simplest way is to just load all entities (EF Core will do the relationship fixup for you) and then just work from the parent you actually care about:

public class File
{
    public int FileId {get; set;}
    public string Name {get; set;}
    public int ParentFolderId {get; set;}

    public Folder ParentFolder {get; set;}

    public string Path => ParentFolder?.Path + "/" + Name;
}

public class Folder
{
    public int FolderId {get; set;}
    public string Name {get; set;}
    public int ParentFolderId {get; set;}
    
    public Folder ParentFolder {get; set;}
    public ICollection<Folder> SubFolders {get; set;} = new HashSet<Folder>();
    public ICollection<Files> Files {get; set;} = new HashSet<File>();

    public string Path => ParentFolder?.Path + "/" + Name;
}

context.Files.Load();
context.Folders.Load();

var rootFolderICareAbout = context.Folders.Local.FirstOrDefault(f => f.Path == "/My/Folder/Path");

Here is another way, that works with a single model class:

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace IssueConsoleTemplate
{
    public class FileOrFolder
    {
        public int FileOrFolderId { get; set; }
        public string Name { get; set; }
        public bool IsFolder { get; set; }
        public int? ParentId { get; set; }

        public FileOrFolder Parent { get; set; }
        public ICollection<FileOrFolder> Children { get; set; } = new HashSet<FileOrFolder>();

        public bool HasAncestor(FileOrFolder parent) => Parent == parent || (Parent?.HasAncestor(parent) ?? false);
    }

    public class Context : DbContext
    {
        public DbSet<FileOrFolder> FileOrFolders { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                .UseSqlServer(@"Data Source=.\MSSQL14;Integrated Security=SSPI;Initial Catalog=So62854494")
                .UseLoggerFactory(
                    LoggerFactory.Create(
                        b => b
                            .AddConsole()
                            .AddFilter(level => level >= LogLevel.Information)))
                .EnableSensitiveDataLogging()
                .EnableDetailedErrors();
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<FileOrFolder>(
                entity =>
                {
                    entity.HasOne(f => f.Parent)
                        .WithMany(f => f.Children)
                        .HasForeignKey(f => f.ParentId);

                    entity.HasData(
                        new FileOrFolder {FileOrFolderId = 1, Name = "RootFolder", IsFolder = true, ParentId = null},
                        new FileOrFolder {FileOrFolderId = 2, Name = "RootFile1", IsFolder = false, ParentId = 1},
                        new FileOrFolder {FileOrFolderId = 3, Name = "RootFile2", IsFolder = false, ParentId = 1},
                        new FileOrFolder {FileOrFolderId = 4, Name = "SubFolder", IsFolder = true, ParentId = 1},
                        new FileOrFolder {FileOrFolderId = 5, Name = "SubFile1", IsFolder = false, ParentId = 4},
                        new FileOrFolder {FileOrFolderId = 6, Name = "SubFile2", IsFolder = false, ParentId = 4});
                });
        }
    }
    
    internal static class Program
    {
        private static void Main()
        {
            using var context = new Context();
            
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            var rootFolder = context.FileOrFolders.Single(f => f.ParentId == null);

            var subFilesOrFolders = context.FileOrFolders
                .Include(f => f.Parent)
                .AsEnumerable()
                .Where(f => f.HasAncestor(rootFolder))
                .ToList();
            
            Debug.Assert(subFilesOrFolders.Count == 5);
            Debug.Assert(subFilesOrFolders.First(f => f.IsFolder).Name == "SubFolder");
            Debug.Assert(subFilesOrFolders.First(f => f.IsFolder).Children.Count == 2);
        }
    }
}

These are quick and simple solutions. For better performance, you need to issue hierarchical queries.

Related