Building Facet Queries with PredicateBuilder

Posted 11/06/2013 by vconcepcion

Been working on a Sitecore 7 faceted search application with a common UI scenario. There are multiple facets, each of which has a set of checkboxes representing facet values. A user may select multiple checkboxes under each facet, and selected values are joined using a boolean 'OR' operator. When values are selected across multiple facets, the facet 'OR' subqueries are joined to each other using a boolean 'AND'.

For example:

Facet UI
Logical search query: ('Afternoon' OR 'Evening') AND ('Nonstop' OR '1 Stop') AND ('Economy')

To keep things clean and search provider-agnostic, I decided to build a LINQ to Sitecore query. I came across a couple utilities Sitecore uses to convert simple raw string queries to LINQ queries:

  • Sitecore.Buckets.Util.UIFilterHelpers - parses the search queries syntax used in rendering data sources and returns an IEnumerable<SearchStringModel>
  • Sitecore.ContentSearch.Utilities.LinqHelper - takes an IEnumerable<SearchStringModel> and returns an IQueryable<SitecoreUISearchResultItem> which can be used in LINQ queries

Unfortunately, I wasn't able to find any examples of raw string queries that could support the logic described in the example above. Furthermore, the return type of the LinqHelper is IQueryable<SitecoreUISearchResultItem> and I wanted to use properties of my own POCO in my LINQ query.

Somewhere deep within the 85-page "Developer's Guide to Item Buckets and Search" (as well as in a couple StackOverflow posts) you will find hints of using the Sitecore PredicateBuilder helper to build dynamic queries that support custom boolean logic. The Sitecore 7 Development team blog has a post which explains what the PredicateBuilder is at a high level and details various use cases for it.

The methods below demonstrate how this turned out to be quite helpful for our use case.

        /// <summary>
        /// Return site search results for the given request object (hits and total result count)
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        public SearchResults<SiteSearchResultItem> GetSiteSearchResults(SearchRequest request)
        {
            Assert.ArgumentNotNull(request, "request");
            using (var context = _index.CreateSearchContext())
            {
                // Build filtering clause based on selected refinements
                var refinementPredicate = GetSiteSearchRefinementPredicate(request);

                IQueryable<SiteSearchResultItem> query = context.GetQueryable<SiteSearchResultItem>()
                    .Where(item => item.Content == request.QueryText)
                    .Where(refinementPredicate);

                return query.GetResults();
            }
        }

        /// <summary>
        /// Builds a predicate for the selected refinements in the request
        /// using the 'OR' operator to join refinements within a refinement group
        /// and the 'AND' operator to join refinement groups
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="request"></param>
        /// <returns></returns>
        protected Expression<Func<SiteSearchResultItem, bool>> GetSiteSearchRefinementPredicate(SearchRequest request)
        {
            Expression<Func<SiteSearchResultItem, bool>> predicate = PredicateBuilder.True<SiteSearchResultItem>();
            foreach (string refinementCategory in request.Refinements.Keys)
            {
                Expression<Func<SiteSearchResultItem, bool>> innerPredicate = PredicateBuilder.False<SiteSearchResultItem>();

                // Tag refinements - join multiple selections within a category using 'OR' operator
                predicate = predicate.And(i => i[refinementCategory] != null);
                foreach (string refinementValue in request.Refinements[refinementCategory])
                {
                    ID tagId;
                    if (ID.TryParse(refinementValue, out tagId))
                    {
                        innerPredicate = innerPredicate.Or(i => i[(ObjectIndexerKey)refinementCategory] == tagId);
                    }
                }
                
                // Join selections across refinement categories using "AND" operator
                predicate = predicate.And(innerPredicate);
            }
            return predicate;
        }

A few things to note regarding the above code:

  • To give a bit more background, we had implemented a simple tagging system, whereby each refinement, or facet value, was the ID of a Tag item in Sitecore. (Note: we didn't go the route of creating tag repositories as described in the Item Buckets documentation since there were only a handful of predefined tags for each facet.)
  • For conciseness, I didn't include the definition of the SearchRequest class, but only thing to note here is that the Refinements property of the request object is just a Dictionary<string, List<string>> which stores the selected facet values indexed by facet name (i.e., indexed field name).
  • ObjectIndexerKey - By casting your index key to this, it will return an object whose type is implicitly casted (cast?) to the type of the object it is being compared to. For example, since tagId is of type Sitecore.Data.ID, the value of i[(ObjectIndexerKey)refinementCategory] will be inferred to match it. Something I found interesting here is that in the example above, the left side of the comparison actually represents an IEnumerable<ID> (multilist) stored in the index, but using the "==" operator against tagId actually seems to result in something like a .Contains(...) expression.
  • I tested this using the Solr provider. Haven't tried it in Lucene yet, but if the LINQ layer does its job, I figure that should work as well.

Hope this encourages people to take advantage of the LINQ provider and highlights some useful query-building utilities that come out-of-the-box with Sitecore 7.

Share:

Archive

Syndication