4

My app allows users to assign categories to their items. The classes look like this.

class Item
{
    public string Id { get; set; }
    /* ... */
    public virtual IEnumerable<Category> Categories { get; set; }
}

class Category
{
    public string Id { get; set; }
    public virtual Category Parent { get; set; }
    public virtual IEnumerable<Category> Subcategories { get; set; }
}

As you can see from the above there are are a hierarchy between categories.

If I have the following category tree:

|-- Item 1
|---- Child 1
|---- Child 2
|---- Child 3
|-- Item 2

And the user wants to display Item1, I want to include all the categories for Child 1, 2 and 3 in the result, i.e. four categories should be included (Item 1, Child 1, Child 2, Child 3) in the query.

How can I do this with EntityFrameworkCore. I'm using SQLite as a backend but would prefer to do this without SQL if possible.

Sam
  • 4,219
  • 7
  • 52
  • 80
  • 1
    Just to be clear: In your example, `Child 1, 2 and 3` are categories under `Item 1`, correct? What you say is that `Child 1` could have it's own categories, and you want to include them as well? – jpgrassi Aug 08 '19 at 15:09
  • @jpgrassi correct, so if the user selects Item 1, then the items which are in Child 1, 2, 3, in addition to Item 1, should all be shown. – Sam Aug 08 '19 at 20:46
  • Is changing the classes (db schema) an option? – Magnus Aug 14 '19 at 07:39
  • @Magnus yes, if absolutely required – Sam Aug 14 '19 at 20:50

4 Answers4

1

Have you tried using the ToString() method of DbFunction

ToString() will print the current object. So, It's children will also be printed. you need to override this method in Item class.

Harsh Mishra
  • 1,975
  • 12
  • 15
1

Entity Framework is quite convenient with all it's automation, but unfortunately, like most things in life, it has yet to master every tricky situation out there, this being one of them. (Although to be fair, the problem pretty much lies within storing hierarchical data in a relational database).

I tend to solve similar situations by "cheating" a bit, at least whenever possible/suitable, by introducing some kind of additional property/column to group them, and then simply load them all, and do the relational mapping by hand, which is usually quite simple.

Loading additional data in one database call is often to prefer before making multiple calls. (You might still have to sneak around any lurking db-admin though).

Assuming you're planning for a situation with potentially N amount in breadth and M amount in depth (if not, the other answers should suffice), it's a quick and dirty solution which in worst case at least gets the job done.

To stick with EF, the idea is essentially to first decouple the relationships that EF might have mapped and use simple value types as reference: (It's not really a necessity, but something I tend to prefer)

class Item
{
    public string Id { get; set; }

    public virtual IEnumerable<Category> Categories { get; set; }
}

class Category
{
    public string Id { get; set; }

    // We drop the parent reference property and add a simple ParentId property instead,
    // hopefully saving us some future headache.
    //
    public string ParentId { get; set; }

    //public virtual Category Parent { get; set; } // Goodbye dear friend, you have served us well.

    // Depending on how you're configuring, we might have to "loose" some EF-mapped relationships,
    // [NotMapped] is merely an example of that here, it's not neccessarily required.
    [NotMapped]
    public virtual IEnumerable<Category> Subcategories { get; set; }

    // As an example, I've just added the item id as our category scope/discriminator,
    // allowing us to limit our full query at least somewhat.
    //
    public string ItemId { get; set; }
}

Now we're ready to do what EF does best. Load and map data! We will load a plan list of the category-entities all by themselves, without any direct relationships to anything else, and then map them ourselves.

To make it maintainable, let's create a neat little static class and add some useful extensions methods that will assist us, starting with the initial DbContext-load.

public static class CategoryExtensions 
{
    /// <summary>
    /// Extension method to find and load all <see cref="Category"/> per <see cref="Category.ItemId"/>
    /// </summary>
    public static List<Category> FindCategoriesForItemId(this DbContext dbContext, string itemId)
        => dbContext.Set<Category>()
            .Where(c => c.ItemId == itemId)
            .ToList();
}

Once we're able to easily load categories, it would be useful to be able to map the children and possibly flatten them/any subcategory if necessary, so we throw two more methods in there, one to map child categories to all the categories we've found, and one to flatten a hierarchically structure we might have in the future (or just for fun).


/// <summary>
/// Flattens the IEnumerable by selecting and concatenating all children recursively
/// </summary>
/// <param name="predicate">Predicate to select the child collection to flatten</param>
/// <returns>Flat list of all items in the hierarchically constructed source</returns>
public static IEnumerable<TSource> Flatten<TSource>(this IEnumerable<TSource> source, Func<TSource, IEnumerable<TSource>> predicate)
    => source.Concat(source.SelectMany(s => predicate(s).Flatten(predicate)));


/// <summary>
/// "Overload" for above but to use with a single root category or sub category...
/// </summary>
public static IEnumerable<TSource> Flatten<TSource>(this TSource source, Func<TSource, IEnumerable<TSource>> predicate)
    => predicate(source).Flatten(predicate);


/// <summary>
/// For each entry in the <paramref name="flatSources"/>, 
/// finds all other entries in the <paramref name="flatSources"/> which has
/// a <paramref name="parentRefPropSelector"/> value matching initial entries
/// <paramref name="identityPropSelector"/>
/// </summary>
/// <param name="flatSources">Flat collection of entities that can have children</param>
/// <param name="identityPropSelector">Selector Func to select the identity property of an entry</param>
/// <param name="parentRefPropSelector">Selector Func to select the parent reference property of an entry</param>
/// <param name="addChildren">Action that is called once any children are found and added to a parent entry</param>
public static IEnumerable<TSource> MapChildren<TSource, TKey>(
    this IEnumerable<TSource> flatSources,
    Func<TSource, TKey> identityPropSelector,
    Func<TSource, TKey> parentRefPropSelector,
    Action<TSource, IEnumerable<TSource>> addChildren)
    => flatSources.GroupJoin(   // Join all entityes...
        flatSources,            // ... with themselves.
            parent => identityPropSelector(parent), // On identity property for one...
            child => parentRefPropSelector(child),  // ... And parent ref property for another.
            (parent, children) =>  // Which gives us a list with each parent, and the children to it...
            {
                addChildren(parent, children); // ... Which we use to call the addChildren action, leaving adding up to the caller
                return parent;
            });

That's it. It's not perfect, but, in my opinion, it's a decent enough starter solution that still takes advantage of EF and doesn't complicate it too much. Only worry is if the amount of categories loaded becomes too large, but at that point, it will be well worth spending some actual time on a more "proper" solution. (I haven't actually tested that MapChildren-extension, and there's a lot of room for improvements to it, but I hope it helps to illustrate the idea.)

To eventually actually use it, it ends up looking something like this:

/// <summary>
/// Loads and structures all categories related to <see cref="itemId"/> 
/// and returns first <see cref="Category"/> where <see cref="Category.ParentId"/>
/// is null.
/// </summary>
public Category GetMeRootCategorylore(string itemId)
{
    using (var dbContext = new DbContext())
    {
        var mappedAndArmedCategories
            = dbContext // Use our db context...
                .FindCategoriesForItemId(itemId) // To find categories..
                .MapChildren(           // And then immediately map them, which comes close to what we're used with when using EF.
                    parent => parent.Id,    // Set the identity property to map children against
                    child => child.ParentId, // Set the parent references to map with
                    (parent, children) => parent.Subcategories = children); // This is called when children have been found and should be mapped to the parent.

        // Oh noes, what if I need a flattened category list later for whatever reason! (Or to do some real lazy loading when looking a single one up!)
        // ... Aha! I almost forgot about our nifty extension method to flatten hierarchical structures!
        //
        var flattenedList = mappedAndArmedCategories.Flatten(c => c.Subcategories);

        // Maybe we'll pick up a root category at some point
        var rootCategory = mappedAndArmedCategories.FirstOrDefault(c => c.ParentId == null);

        // And perhaps even flatten it's children from the single category node:
        var subFlattenedList = rootCategory?.Flatten(c => c.Subcategories);

        // But now we've had enough fun for today, so we return our new category friend.
        return rootCategory;
    }
}

Finally, here's a quite informative and helpful question about hierarchical data in relational databases if you'd like to dig in deeper or get some other ideas: What are the options for storing hierarchical data in a relational database?

Classe
  • 256
  • 1
  • 6
  • Thanks for the answer. I've not had a chance to implement yet but either way loading all and manually sorting isn't something I'd considered, and there's not likely to be more than a dozen or so rows. – Sam Aug 15 '19 at 21:08
0

You may fetch grandchildren like this:

Considering eager load

    public List<Category> GetCategories(int itemId)
    {
        Category categoryChildren = _context.Set<Category>()
            .Include(i => i.Subcategories)
            .ThenInclude(i => i.Category)
            .FirstOrDefault(w => w.ItemId == itemId);

        var categories = new List<Category>();
        if (categoryChildren == null)
            return categories;

        // get children
        categories.AddRange(categoryChildren.Subcategories.Select(s => s.Category));

        // get grandchildren 
        foreach (var subCategory in categoryChildren.Subcategories.Select(s => s.Category))
        {
            _context.Entry(subCategory).Collection(b => b.Subcategories).Load();

            foreach (var categoryGrandChildren in subCategory.Subcategories)
            {
                _context.Entry(categoryGrandChildren).Reference(b => b.Category).Load();

                // check if not adding repeatables 
                if (!categories.Any(a => a.Id == categoryGrandChildren.Id))
                    categories.Add(categoryGrandChildren.Category);
            }
        }

        return categories;
    }

If you're using lazy load you don't even need .Include and .Load methods.

Jaime Yule
  • 981
  • 1
  • 11
  • 20
0
public void PrintAllItems() //Use Take or where to fetch you specfic data
{
  var allItems = context.Items
    .Include(item=> item.Categories)
        .ThenInclude(cat=>cat.Subcategories)         
    .ToList();


  foreach(var item in allItems)
  {
      Console.WriteLine(item.Id);
      foreach(var category in item.Categoires)
      {
        Console.WriteLine(category.Id);
        foreach(var sub in category.Subcategories)
        {
           Console.WriteLine(sub.Id);
        }
      }
  }
}


public void FirstItem(string Id) //Use Take or where to fetch you specfic data
{
  var allItems = context.Items
    .Include(item=> item.Categories)
        .ThenInclude(cat=>cat.Subcategories)         
    .FirstOrDefault(g=>g.Id==Id);


  foreach(var item in allItems)
  {
      Console.WriteLine(item.Id);
      foreach(var category in item.Categoires)
      {
        Console.WriteLine(category.Id);
        foreach(var sub in category.Subcategories)
        {
           Console.WriteLine(sub.Id);
        }
      }
  }
}
Eldho
  • 7,795
  • 5
  • 40
  • 77