Performing a geospatial search with Lucene on a document that has multiple locations

I was working on a Sitecore project where a single product item could be tagged with multiple location items, as in this product is available in these locations. The requirements for search indicated that we needed to be able to search for products by their properties and list them in order of distance from the user, if the user provided a zip code or address. This would be straight forward if each product were tagged with a single location. In that case I could create a computed index field for product items and store the geospatial search values directly on the product. Then I would query for products based on whatever criteria was passed in and the location.

With multiple locations tagged to one product, the problem is that Lucene can’t do multi point searches on single items. The first thing that came to mind was to search for locations and products in two separate queries and intersect the two. If that were done in memory, then the performance in a production environment would tank. The other option is to do a Lucene intersect but unfortunately, this does not seem to work as expected with spatial and regular queries because of the different search context that Lucene.Spatial.Contrib needs vs regular Lucene search.

The solution, admittedly a bit cumbersome, was to use Sitecores Links Database to populate two computed index fields on Locations. One with just with the unique TemplateIDs of items that reference them, and one with the specific item IDs of the items that reference them. So the user query is split into two, first the spatial search on Locations, with a reference template filter. In this case the TemplateID to filter by would be the ID of the Product template. Then the location result items would provide a list of IDs to filter the Product search on.

The Linked Templates Computed Field

public class LocationLinkedTemplatesComputedField : IComputedIndexField
{
	public string FieldName { get; set; }

	public string ReturnType { get; set; }

	public object ComputeFieldValue(IIndexable indexable)
	{
		Item item = indexable as SitecoreIndexableItem;
		if (item == null || !item.Paths.IsContentItem || item.TemplateName != "Location")
		{
			return null;
		}

		var references = Globals.LinkDatabase.GetItemReferrers(item, true);

		var map = references.Select(x => x.SourceItemID.GetItem(x.SourceDatabaseName)).Compact().Select(x => x.TemplateID).Distinct();

		return map;
	}
}

The corresponding property in the SearchResultItem
        [IndexField("location_linkedtemplates")]
        [TypeConverter(typeof(IndexFieldEnumerableConverter))]
        public virtual IEnumerable<ID> LinkedTemplateIds { get; set; }

The config value
<field fieldName="location_linkedtemplates" >MyAssembly.LocationLinkedTemplatesComputedField,MyAssembly</field>

The Linked IDs Mapping Computed Field

public class LocationLinkMapComputedField : IComputedIndexField
{
	public string FieldName { get; set; }

	public string ReturnType { get; set; }

	public object ComputeFieldValue(IIndexable indexable)
	{
		Item item = indexable as SitecoreIndexableItem;
		if (item == null || !item.Paths.IsContentItem || item.TemplateName != "Location")
		{
			return null;
		}

		var references = Globals.LinkDatabase.GetItemReferrers(item, true);

		var map =
			references.Select(x => x.SourceItemID.GetItem(x.SourceDatabaseName))
				.Compact()
				.Select(x => new KeyValuePair<ID, ID>(x.TemplateID, x.ID))
				.SerializeToJson();

		return map;
	}
}

The corresponding property in the SearchResultItem
        [IndexField("_mt_location_linkmap")]
        [TypeConverter(typeof(IndexFieldEnumerableConverter))]
        public virtual string LinkMap { get; set; }

The config value
<field fieldName="location_linkmap" >MyAssembly.LocationLinkMapComputedField,MyAssembly</field>

Above, the map is serialized to JSON because Dictionaries and Lookups don’t work natively when being converted to a property on a Sitecore SearchResultItem. There may be a case for writing a custom Index Field converter based on one of those that are provided by Sitecore, such as IndexFieldEnumerableConverter, but that seemed like a lot of work for little reward.

The search logic first gets the location items,

public IEnumerable<LocationResultItem> GetResultItems(IEnumerable<string> LinkedTemplateIds)
{
	var query = this.SearchContext.GetQueryable<LocationResultItem>()
	var predicate = PredicateBuilder.True<LocationResultItem>();
	foreach (var templateId in LinkedTemplateIds)
	{
		predicate = predicate.And(x => x.LinkedTemplateIds.Contains(templateId));
	}

	query = query.Where(predicate);

	return query;
}

Then it gets the associated content ids

public IEnumerable<ID> GetIds(IEnumerable<LocationResultItem> locations)
{
	var allIds = new List<ID>();
	foreach (var locationResult in locations)
	{
		var entry = locationResult.LinkMap;
		var map = entry.DeserializeFromJson<IEnumerable<KeyValuePair<ID, ID>>>();
		foreach (var templateId in searchParams.LinkedTemplateIds)
		{
			var ids = map.Where(x => x.Key == templateId).Select(x => x.Value);
			allIds.AddRangeUnique(ids);
		}
	}
	return allIds;
}

Finally we conduct a search for the product content type, with the list of IDs passed into the query,

public IQueryable<ProductResultItem> ApplyLocationLinkMapIdQuery(
	IQueryable<ProductResultItem> query, 
	IEnumerable<ID> linkedItemIds)
{	
	if (linkedItemIds.Any())
	{
		var predicate = PredicateBuilder.True<ProductResultItem>();
		predicate = searchParam.LinkedItemIds.Aggregate(
			predicate, 
			(current, id) => current.Or(x => x.ItemId == id));

		query = query.Where(predicate);
	}
	else
	{
		// If no locations then no results
		query = query.Where(x => x.ItemId == Guid.Empty.ToID());
	}
	
	return query;
}

Leave a Reply

Your email address will not be published. Required fields are marked *