Creating custom search documents

Creating custom search documents is useful when the data isn’t coming from a CMS data type, or when data from multiple data types should be combined into one type of a document.

To implement custom search documents one should register an implementation of ISearchDocumentSourceProvider

namespace Composite.Search
{
    public interface ISearchDocumentSourceProvider
    {
        IEnumerable<ISearchDocumentSource> GetDocumentSources();
    }

    public interface ISearchDocumentSource
    {
        string Name { get; }
        IEnumerable<SearchDocument> GetAllSearchDocuments(CultureInfo culture);
        ICollection<DocumentField> CustomFields { get; }
        void Subscribe(IDocumentSourceListener sourceListener);
    }
}

For an example, let’s imagine we have the following data types:

Product, Tag and ProductTag that defines a many to many relationship between the products and tags.

We want the products to be indexed, along with tags, so one can see the tags in the search results as well as apply faceted search by the tag names

Startup handler that’s registering the data types and a search document source provider:

[ApplicationStartup(AbortStartupOnException = false)]
public class Startup
{
	public static void ConfigureServices(IServiceCollection collection)
	{
		collection.AddSingleton<ISearchDocumentSourceProvider>(new CustomDocumentSourceProvider());
	}


	public static void OnBeforeInitialize() {}


	public static void OnInitialized()
	{
		DynamicTypeManager.EnsureCreateStore(typeof(Product));
		DynamicTypeManager.EnsureCreateStore(typeof(Tag));
		DynamicTypeManager.EnsureCreateStore(typeof(ProductTag));
	}
}

Document source:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Composite.Data;
using Composite.Search;
using Composite.Search.Crawling;

namespace SearchExamples
{
    class CustomDocumentSourceProvider : ISearchDocumentSourceProvider
    {
        private readonly ISearchDocumentSource _products;

        public CustomDocumentSourceProvider(ProductSearchDocumentSource products)
        {
            _products = products;

        }

        public IEnumerable<ISearchDocumentSource> GetDocumentSources()
        {
            yield return _products;
        }
    }

    class ProductSearchDocumentSource : ISearchDocumentSource
    {
        public const string TagsFieldName = "product.tags";

        public string Name => "Products";

        private IEnumerable<ISearchDocumentBuilderExtension> _extensions;

        public ProductSearchDocumentSource(IEnumerable<ISearchDocumentBuilderExtension> extensions)
        {
            _extensions = extensions;
        }

        public IEnumerable<DocumentWithContinuationToken> GetSearchDocuments(CultureInfo culture, string continuationToken)
        {
            using (var conn = new DataConnection(PublicationScope.Published, culture))
            {
                var products = conn.Get<Product>().ToList();
                var tags = conn.Get<Tag>().ToDictionary(tag => tag.Id, tag => tag.Name);
                var productTags = conn.Get<ProductTag>()
                    .GroupBy(pt => pt.Product)
                    .ToDictionary(group => group.Key, group => group.Select(pt => pt.Tag).ToArray());

                foreach (var product in products)
                {
                    Guid[] tagIds;
                    string[] tagNames = productTags.TryGetValue(product.Id, out tagIds)
                        ? tagIds.Select(id => tags[id]).ToArray()
                        : null;

                    yield return new DocumentWithContinuationToken
                    {
                        Document = CreateSearchDocument(product, tagNames),
                        ContinuationToken = null
                    };
                }
            }
        }

        private SearchDocument CreateSearchDocument(Product product, string[] tagNames)
        {
            var builder = new SearchDocumentBuilder(_extensions);

            builder.SetDataType(typeof(Product));

            // Extracting search fields and text parts from the product object
            builder.CrawlData(product);
            builder.Url = ${body}quot;~/product({product.Id})";

            // Creating a search document
            var document = builder.BuildDocument(this.Name,
                product.Id.ToString(),
                product.Name,
                null,
                product.GetDataEntityToken());

            if (tagNames != null)
            {
                // Setting the facet values
                document.FacetFieldValues[TagsFieldName] = tagNames;

                // Generating a comma separated list to show in preview
                document.FieldValues[TagsFieldName] = string.Join(", ", tagNames.OrderBy(a => a));
            }

            return document;
        }


        public IReadOnlyCollection<DocumentField> CustomFields =>
            //If only the default fields are expected, use SearchDocumentBuilder.GetDefaultDocumentFields() instead
            DataTypeSearchReflectionHelper.GetDocumentFields(typeof(Product))
            .Concat(new[]
            {
                new DocumentField(TagsFieldName,
                    new DocumentFieldFacet
                    {
                        MinHitCount = 1,
                        FacetType = FacetType.MultipleValues,
                        PreviewFunction = value => (string) value
                    },
                    new DocumentFieldPreview
                    {
                        Sortable = false,
                        PreviewFunction = value => (string) value
                    })
                {
                    Label = "Category"
                }
            }).ToList();


        public void Subscribe(IDocumentSourceListener sourceListener)
        {
        }
    }
}