List all products from child categories on uCommerce

One question I’ve seen batted around a lot is how to list all products from the child categories when viewing a parent category. On the uCommerce demo store Avenue Clothing we simply loop through the category and all child categories to output the products:

src\uCommerce.RazorStore\App_Code\uCommerce\Helpers\Category.cshtml

@using UCommerce.Api
@using UCommerce.Extensions
@using UCommerce.EntitiesV2
@using UCommerce.Runtime
@using umbraco.MacroEngines
@helper DisplayCategoryProducts(UCommerce.EntitiesV2.Category category)
{
    var products = CatalogLibrary.GetProducts(category);
    @uCommerce.Helpers.Product.DisplayListProducts(products, category)
    foreach (var childCategory in category.Categories)
    {
        @DisplayCategoryProducts(childCategory)
    }
}

The problem with this however is if you have either a lot of categories or a lot of products in the categories it’s not the nicest of outputs (a big page of products) and it’s also not very performant. So what’re the options? If we split the logic into two steps we can improve this dramatically for both the user and the server.

Imagine the following category/product structure:

  1. Catalog
    1. Category 1
      1. Product A
      2. Product B
      3. Product C
      4. Category 2
        1. Product D
        2. Product E
    2. Category 3
      1. Product F
      2. Product G
      3. Product H
    3. Category 4
      1. Product I
      2. Product J
      3. Product K
      4. Product L

 

The idea is that when viewing "Catalog" it would list Product A-J on page 1 and Product K-L on page 2 and if you were on "Category 1" it would show A-E.

The way we go about this is to first get the list of categories we’re interested in and then we’ll use that to get the products that are in that category before sorting and paging them. In the past we would have had to do this with HQL (NHibernate’s SQL) and I have code samples that do it that way if you need but uCommerce now has the power of RavenDB behind it which is perfect for this sort of task.

The trick with this is to flatten the category structure out so you can select the starting category to  search "down" from. we do this with a "Flatten" function as seen below. Although there's a lot more code to go about it this way, the speed improvements are vast -for a catalogue with a hundred or so categories and tens of thousands of products, we can return the data in a few milliseconds!

 

public ActionResult ProductList()
{
    // Get the current category
    var category = SiteContext.Current.CatalogContext.CurrentCategory;

    // Get the category ids of this category and all it's children
    var categoryIds = GetCategoryIds(category.Id);

    // Get the products for the categories
    var products = GetProductsInCategories(categoryIds);

    // Build your ViewModel
}

private static IEnumerable GetCategoryIds(int categoryId)
{
    // Get all categories from Raven
    var completeCategoryTree = GetCategories();

    // Remove any nesting so we can find the category we're after
    var flattendCategories = GetFlattenedCategories(completeCategoryTree);

    // Get the category we're starting from
    var categories = flattendCategories.Where(c => c.Id == categoryId).ToList();
    
    // Add the ids of the child categories
    categories.AddRange(GetFlattenedCategories(categories).ToList());

    // Just in case, only return the unique categories
    var categoryIds = categories.Select(c => c.Id).Distinct();
    return categoryIds;
}

private static IList GetCategories()
{
    // First get the entire catalog from Raven as it includes the categories we need
    var catalog = GetProductCatalogFromRaven();
    
    if (catalog == null)
        return Enumerable.Empty().ToList();

    // Remove any categories that either have no products -or are inactive
    var categories = RemoveHidden(catalog.Categories).ToList();

    // Sort the categories just in case Raven hasn't persisted them in the right order
    return SortCategories(categories.ToList());
}

private static ProductCatalog GetProductCatalogFromRaven()
{
    var repository = ObjectFactory.Instance.Resolve();

    // Take the first catalog, if you have multiple catalogs you'll need to extend this
    return repository.Select().FirstOrDefault();
}

private static IEnumerable RemoveHidden(IEnumerable categories)
{
    var visibleCategories = categories.Where(c => c.Display && (c.ProductIds.Any() || ChildExists(c.Categories, cc => cc.Categories, p => p.ProductIds.Any()))).ToList();
    foreach (var category in visibleCategories)
    {
        category.Categories = RemoveHidden(category.Categories).ToList();
    }
    return visibleCategories;
}

private static IList SortCategories(IList categories)
{
    if (categories == null || !categories.Any())
        return Enumerable.Empty().ToList();

    foreach (var category in categories)
    {
        category.Categories = SortCategories(category.Categories);
    }

    return categories.OrderBy(c => c.SortOrder).ToList();
}

private static IEnumerable GetFlattenedCategories(IEnumerable completeCategoryTree)
{
    var categories = FlattenHierarchy(completeCategoryTree, c => true, c => c.Categories).ToList();
    return categories;
}

// This helper function will take the nested tree of categories and build a new "flat" collection making it easier to start from a given category
private static IEnumerable FlattenHierarchy(IEnumerable source, Func<T, bool> selectorFunction, Func<T, IEnumerable> getChildrenFunction)
{
    var items = source as T[] ?? source.ToArray();

    var flattenedList = items.Where(selectorFunction);
    return items.Aggregate(flattenedList, (current, element) => current.Concat(FlattenHierarchy(getChildrenFunction(element), selectorFunction, getChildrenFunction)));
}

// This helper function will check to ensure that there are child items in the collection
private static bool ChildExists(IEnumerable source, Func<T, IEnumerable> childrenSelector, Predicate condition)
{
    while (true)
    {
        var items = source as T[] ?? source.ToArray();
        if (source == null || !source.Any()) return false;

        var attempt = items.FirstOrDefault(t => condition(t));

        if (!Equals(attempt, default(T))) return true;

        source = items.SelectMany(childrenSelector);
    }
}

// Select the products which are in the category, you could return the list of product ids from the category
// but that would result in many select statements where as this will result in only one
private static IFacetedQueryable GetProductsInCategories(IEnumerable categoryIds)
{
    var query = SearchLibrary.FacetedQuery();
    var productsInCategories = query.Where(x => x.DisplayOnSite && x.AllowOrdering && x.CategoryIds.Any(id => id.In(categoryIds)));
    return productsInCategories;
}

I hope this gives you some ideas on how you can hack around RavenDB. There are additional considerations you'll want to give to this such as how to maintain the sort order so the category sort orders are observed -but that can wait for another day!

Let me know what you think!

Author

Tim

comments powered by Disqus