Bi-directional one-to-many and many-to-many relationships in Sitecore

Sitecore out-of-the-box does not come with a feature built-in that would allow you to setup bi-directional relationship between items. Hence, I have decided to write my own solution and share it with the community.

John has done a great job describing the various ways how item updates can be intercepted in the following post.

The two options that I identified as the most viable were either write a custom Sitecore handler for the OnItemSaved event (option 1) or write a rule (option 4). The advantages for the rule mentioned by John convinced me that this would be the best option. Here are some of the reasons summarised:

  • Control logic and parameters through UI from content authoring environment
  • Separation of concerns: Rule condition under which the rule runs is part of the configuration
  • Rule only executes on the database that it is configured

Let’s have a look at the custom rule action

namespace HelveticSolutions.Sitecore.Rules
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    using global::Sitecore;

    using global::Sitecore.Data;

    using global::Sitecore.Data.Items;

    using global::Sitecore.Diagnostics;

    using global::Sitecore.Links;

    using global::Sitecore.Rules;

    using global::Sitecore.Rules.Actions;

    /// <summary>
    /// The sync bi directional relationship.
    /// </summary>
    /// <typeparam name="T">
    /// </typeparam>
    public class SyncBiDirectionalRelationship<T> : RuleAction<T>
        where T : RuleContext
    {
        /// <summary>
        /// The master database name.
        /// </summary>
        private const string MasterDatabaseName = "master";

        /// <summary>
        /// The is master item.
        /// </summary>
        private readonly Func<ItemLink, bool> isMasterItem =
            link => link.SourceDatabaseName == MasterDatabaseName && link.TargetDatabaseName == MasterDatabaseName;

        /// <summary>
        ///     Gets or sets the source field name.
        /// </summary>
        public string SourceFieldName { get; set; }

        /// <summary>
        ///     Gets or sets the target field name.
        /// </summary>
        public string TargetFieldName { get; set; }

        /// <summary>
        ///     Gets or sets the target template id.
        /// </summary>
        public ID TargetTemplateId { get; set; }

        /// <summary>
        /// Applies this rule action when triggered.
        /// </summary>
        /// <param name="ruleContext">
        /// The rule context.
        /// </param>
        public override void Apply(T ruleContext)
        {
            Item item = ruleContext.Item;
            this.SynchronizeRelationship(item);
        }

        /// <summary>
        /// Adds a link id to a given multi-select field collection on the passed item.
        /// </summary>
        /// <param name="item">
        /// The item being updated.
        /// </param>
        /// <param name="fieldId">
        /// Id for the field that holds the multi-select collection.
        /// </param>
        /// <param name="addedLinkId">
        /// The link id to be added to the multi-select collection.
        /// </param>
        private void AddLinkId(Item item, ID fieldId, ID addedLinkId)
        {
            string fieldValue = item.Fields[fieldId].Value;
            using (new EditContext(item))
            {
                string newFieldValue = !string.IsNullOrEmpty(fieldValue)
                                           ? string.Concat(fieldValue, "|", addedLinkId)
                                           : addedLinkId.ToString();
                item.Fields[fieldId].Value = newFieldValue;
            }
        }

        /// <summary>
        /// Gets all references for a Sitecore item.
        /// </summary>
        /// <param name="item">
        /// The item.
        /// </param>
        /// <returns>
        /// The found references.
        /// </returns>
        private IEnumerable<ItemLink> GetReferences(Item item)
        {
            ItemLink[] referrers = Globals.LinkDatabase.GetItemReferences(item, false);
            return referrers;
        }

        /// <summary>
        /// Gets all referrers for a Sitecore item.
        /// </summary>
        /// <param name="item">
        /// The item.
        /// </param>
        /// <param name="sourceFieldId">
        /// The source field id. When passed in, only referrers on that particular field are returned.
        /// </param>
        /// <returns>
        /// The found referrers.
        /// </returns>
        private IEnumerable<ItemLink> GetReferrers(Item item, ID sourceFieldId = null)
        {
            ItemLink[] referrers = sourceFieldId != null as ID
                                       ? Globals.LinkDatabase.GetReferrers(item, sourceFieldId)
                                       : Globals.LinkDatabase.GetReferrers(item);
            return referrers;
        }

        /// <summary>
        /// Removes a link id from a given multi-select field collection on the passed item.
        /// </summary>
        /// <param name="item">
        /// The item being updated.
        /// </param>
        /// <param name="fieldId">
        /// Id for the field that holds the multi-select collection.
        /// </param>
        /// <param name="removedLinkId">
        /// The link id to be removed from the multi-select collection.
        /// </param>
        private void RemoveLinkId(Item item, ID fieldId, ID removedLinkId)
        {
            string fieldValue = item.Fields[fieldId].Value;
            using (new EditContext(item))
            {
                string newFieldValue = string.Join(
                    "|",
                    fieldValue.Split('|').Where(value => value != removedLinkId.ToString()));
                item.Fields[fieldId].Value = newFieldValue;
            }
        }

        /// <summary>
        /// Synchronize the many-to-many relationship.
        /// </summary>
        /// <param name="item">
        /// The source item that changed.
        /// </param>
        private void SynchronizeRelationship(Item item)
        {
            Assert.IsNotNull(item, "Item cannot be null.");

            ID itemId = item.ID;
            TemplateItem targetTemplate = Database.GetDatabase(MasterDatabaseName).GetTemplate(this.TargetTemplateId);

            Assert.IsNotNull(targetTemplate, "Failed loading the target type template from Sitecore.");

            ID targetFieldId = targetTemplate.Fields.First(field => field.Name == this.TargetFieldName).ID;
            ID sourceFieldId = item.Template.Fields.First(field => field.Name == this.SourceFieldName).ID;

            List<ItemLink> targetReferences =
                this.GetReferrers(item, targetFieldId).Where(link => this.isMasterItem(link)).ToList();
            List<ItemLink> sourceReferences =
                this.GetReferences(item)
                    .Where(link => link.SourceFieldID == sourceFieldId)
                    .Where(link => this.isMasterItem(link))
                    .ToList();

            foreach (ItemLink sourceReference in sourceReferences)
            {
                // Ensure target item links to source item
                ID targetItemId = sourceReference.TargetItemID;
                if (targetReferences.All(m => m.SourceItemID != targetItemId))
                {
                    // Create link to source item
                    this.AddLinkId(sourceReference.GetTargetItem(), targetFieldId, itemId);
                }
            }

            foreach (ItemLink targetReference in targetReferences)
            {
                // Clean up target items linking to source item but are not referenced
                ID sourceItemId = targetReference.SourceItemID;
                if (sourceReferences.All(t => t.TargetItemID != sourceItemId))
                {
                    // Remove link to source item
                    this.RemoveLinkId(targetReference.GetSourceItem(), targetFieldId, itemId);
                }
            }
        }
    }
}

Explanation

This rule is designed to run when either a content manager or some back-end process changes related items on the master database. When this rule is executed from the content authoring environment the current database context is the core database hence using the master database is hard coded in this rule.

This rule at its core uses the Sitecore link database to find related items and then takes corrective actions based on whether an item was added or removed from a list or link control.

Show me how it’s configured

First we create two data templates for the two related entities:

User

UserTemplate

Role

RoleTemplate

Second we create some sample content:

Sample Content

 

Third we configure the rule which requires the following steps:

  1. Add the rule definition:
    Navigate to ‘sitecore/system/settings/rules/definitions/elements/custom’ and create a new Action
    ActionDefinitionIn the text field we add the following:
    sync (one/many)-to-many field [sourceFieldName,,,Source Field Name] with content type [targetTemplateId,Tree,root=/sitecore/templates,Target Template] on [targetFieldName,,,Target Field Name] field
  2. Enable custom rule definitions in your rule editor:
    Navigate to ‘sitecore/system/settings/rules/item saved/tags/default’ and make sure that under Taxonomy ‘Custom’ is part of the Selected collection
    CustomSelected
  3. Create new rule:
    Navigate to ‘sitecore/system/settings/rules/item saved/rules’ and create a new rule using the following settings
    RuleConfiguration

Now we’re ready to test!

Assign a role to one of the sample users and save the item. Then navigate to the role that you just assigned to the sample user and verify that the user is part of the role’s Users collection.

User

UserWithRole

 

Role

RoleWithUsers

Leave a Reply

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