DEV Community

Jesper Mayntzhusen
Jesper Mayntzhusen

Posted on

5 2 1 1 1

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

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

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 😊

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more