DEV Community

Jesper Mayntzhusen
Jesper Mayntzhusen

Posted on

Facetted search with Examine - Umbraco 13

Intro

I've been doing a few projects where we used the new facet engine in Examine 4, have seen several questions about it so figured I throw together a basic example.

This example is using the Umbraco starter kit: https://github.com/umbraco/The-Starter-Kit/tree/v13/dev

All the products have prices between 2 and 1899, the goal now is to set up facets for the price where it shows facet price range values.

To get facets running we first need to do a few things:

Enabling facets

First thing is to update the Examine NuGet package version to one that has facets (as of writing this it is 4.0.0-beta.1).

So after installing it I have this in my csproj:

<PackageReference Include="Examine" Version="4.0.0-beta.1" />
Enter fullscreen mode Exit fullscreen mode

Next I need to ensure that the price field is indexed as a facet field. As this is in the external index it can be changed by adding new config:

In a composer:

public class SearchComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.ConfigureOptions<ConfigureExternalIndexOptions>();

    }
}
Enter fullscreen mode Exit fullscreen mode

ConfigureExternalIndexOptions:

using Examine;
using Examine.Lucene;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;

namespace FacetBlog.Search;

public class ConfigureExternalIndexOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
{
    private readonly IServiceProvider _serviceProvider;

    public ConfigureExternalIndexOptions(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Configure(string name, LuceneDirectoryIndexOptions options)
    {
        if (name.Equals(Constants.UmbracoIndexes.ExternalIndexName))
        {
            // Index the price field as a facet of the type long (int64)
            options.FieldDefinitions.AddOrUpdate(new FieldDefinition("price", FieldDefinitionTypes.FacetTaxonomyLong));
            options.UseTaxonomyIndex = true;

            // The standard directory factory does not work with the taxonomi index.
            // If running on azure it should use the syncedTemp factory
            options.DirectoryFactory = _serviceProvider.GetRequiredService<global::Examine.Lucene.Directories.TempEnvFileSystemDirectoryFactory>();
        }
    }

    public void Configure(LuceneDirectoryIndexOptions options)
    {
        throw new System.NotImplementedException();
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point if the index is rebuilt it will add the facet fields for the price.

Adding a searchservice

Can now add a bit of search logic so show the facet values on the frontend when searching on products. First we add a SearchService:

using Examine;
using Examine.Lucene;
using Examine.Search;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Web.Common;

namespace FacetBlog.Search;

public interface ISearchService
{
    SearchResult SearchProducts(string query);
}

public class SearchResult
{
    public IEnumerable<IPublishedContent> NodeResults { get; set; }
    public IEnumerable<IFacetValue> Facets { get; set; }
}

public class SearchService : ISearchService
{
    private readonly IExamineManager _examineManager;
    private readonly UmbracoHelper _umbracoHelper;

    public SearchService(IExamineManager examineManager, UmbracoHelper umbracoHelper)
    {
        _examineManager = examineManager;
        _umbracoHelper = umbracoHelper;
    }

    public SearchResult SearchProducts(string query)
    {
        var res = new SearchResult();
        var nodeResult = new List<IPublishedContent>();
        var facetResults = new List<IFacetValue>();

        if (_examineManager.TryGetIndex("ExternalIndex", out IIndex? index))
        {
            // Start searching product pages
            var queryBuilder = index
                .Searcher
                .CreateQuery("content")
                .NodeTypeAlias("product");

            // If a query string is added, search the nodenames for that query string
            if (!string.IsNullOrEmpty(query))
            {
                queryBuilder.And()
                    .Field("nodeName", query.MultipleCharacterWildcard());
            }

            // Add facets for the price field split up into several ranges
            var results = queryBuilder
                .WithFacets(f => f.FacetLongRange("price", new[]
                    {
                        new Int64Range("0-100", 0, true, 100, false),
                        new Int64Range("100-500", 100, true, 500, false),
                        new Int64Range("500-1500", 500, true, 1500, false),
                        new Int64Range("1500+", 1500, true, long.MaxValue, true)
                    }))
                .Execute();

            // Loop through results and add to a list of IPublishedContent
            foreach (var result in results)
            {
                nodeResult.Add(_umbracoHelper.Content(int.Parse(result.Id)));
            }

            var priceFacet = results.GetFacet("price");

            // Loop through facet results and add to a list
            foreach (var facetValue in priceFacet)
            {
                facetResults.Add(facetValue);
            }
        }

        res.NodeResults = nodeResult;
        res.Facets = facetResults;

        return res;
    }
}
Enter fullscreen mode Exit fullscreen mode

The main difference to a "normal" search is that before we execute the search we can add a list of facet fields we want to get facets for based on the result set. Because our facet in this example is a number we may not want to get every single number but instead get the ones in certain ranges - that is configured like this:

var results = queryBuilder
    .WithFacets(f => f.FacetLongRange("price", new[]
        {
            new Int64Range("0-100", 0, true, 100, false),
            new Int64Range("100-500", 100, true, 500, false),
            new Int64Range("500-1500", 500, true, 1500, false),
            new Int64Range("1500+", 1500, true, long.MaxValue, true)
        }))
    .Execute();
Enter fullscreen mode Exit fullscreen mode

Then once we retrieve the result the facets can be gotten based on the facet fieldname:

var priceFacet = results.GetFacet("price");
Enter fullscreen mode Exit fullscreen mode

Now in the view we can add a bit of markup with an input field and outputting a list of results and a list of facets:

<form action="@Model.Url()" method="get">
    <input type="text" placeholder="Search" name="query" value="@Context.Request.Query["query"].FirstOrDefault()"/>
    <button>Search</button>
</form>
<div>
    @if (Model.SearchResults.NodeResults.Any())
    {
        <p>Content results:</p>
        <ul>
            @foreach (var content in Model.SearchResults.NodeResults)
            {
                <li>
                    <a href="@content.Url()">@content.Name</a>
                </li>
            }
        </ul>

        <p>Facet results:</p>
        <ul>
            @foreach (var facet in Model.SearchResults.Facets)
            {
                <li>
                    @facet.Label - amount: @facet.Value
                </li>
            }
        </ul>
    }
    else if(Model.HasSearched)
    {
        <p>No results found</p>
    }
</div>
Enter fullscreen mode Exit fullscreen mode

And now we get something like this:

Image description

And with a query:

Image description

Outro

This is a simple example so there are likely several use cases that are not covered - if you have any specific requests for what I can cover next let me know in a comment 🙂.

Feel free to reach out to me on Mastodon and let me know if you liked the blogpost: https://umbracocommunity.social/@Jmayn

Top comments (2)

Collapse
 
davidpeckuk profile image
David Peck

Great post. Your timing is perfect. There's not much online already about how to do this, this is a perfect start for ten.

Collapse
 
jemayn profile image
Jesper Mayntzhusen

Thanks David!

I know it's been on my list for a while, just had some trouble narrowing it down to a useable example that wasn't humongous 😊