CONTENTSTART
EXCLUDESTART EXCLUDEEND

Attaching Node-to-Object Binding Data with Documents

Already read this article and just looking for the sample code files?  Grab em' here!

Why not an article on Kentico 12?

I know what you’re probably thinking: "Kentico 12 just launched and you are blogging on something OTHER than the new version?"  And the answer is, yes.  I will be posting next month on how to make the transition from Portal to MVC (once I get some testing done myself), so for now I want to tie in one more development item that will help you in MVC and Kentico.

How does this connect with MVC?

In my previous article, I described a new module and new way of relating objects to a page.  It was on Node Categories and a better interface for Page Relationships, both which will help you better organize and relate your content which will make getting your objects in your MVC controllers much easier.

Why an article on binding relationships on Nodes?

While I was working with the Node Categories functionality of the Relationships Extended module, I discovered that by default, if you have a Node as a Parent to your binding relationship, it doesn’t automatically stage with the page.  I was baffled as usually if you configure a binding relationship to have a Parent type, and set the SynchronizationSettings to TouchParent, it will create an update for the parent object and include your binding relationships automatically.  It seems that unlike custom-built Objects, Pages are much more complex, and thus require a custom work.  Think about it, a Page update really isn’t a "Node" update, it’s an update on the Document pdate with Node, Versions, Related page relationships, etc, all packed into it.  In order to have Node categories create an update for it's document, and handle the staging tasks, I needed do some heavy lifting.

LogSynchronization = TouchParent vs. LogSynchronization

There are 2 ways you can synchronize a binding object.  You can treat the Binding object as a normal Table with 2 referencing fields, where an insert / update / delete will trigger an individual Staging task with just itself, or you can have an it where an insert / update / delete triggers an update on the Parent object.

There are pros and cons to both scenarios. 

Treating Binding objects as separate tasks (LogSynchronization)

If you decide to keep the binding tasks separate, you would configure your Node Binding object’s TypeInfo like this:

DependsOn = new List<ObjectDependency>()
{
    new ObjectDependency("CategoryID", "cms.category", ObjectDependencyEnum.Required),
    new ObjectDependency("NodeID", "cms.node", ObjectDependencyEnum.Required)
},
SynchronizationSettings =
{
    // Logging is handled separately
    LogSynchronization = SynchronizationTypeEnum.LogSynchronization,
    ObjectTreeLocations = new List<ObjectTreeLocation>()
    {
        // Adds the custom class into a new category in the Global objects section of the staging tree
        new ObjectTreeLocation(GLOBAL, "TreeCategories")
    },
},

The benefit of this is that firstly, it requires no customization (you can ignore the rest of this article pretty much).  It also allows you to push relationships without pushing the actual page content, in case you want to update a relationship without requiring existing page changes (which may not be ready to go live) from going live.

However, you cannot easily sync a Node’s binding relationships this way.  You can do a full sync on the entire Binding Table, but it pushes everything, plus if a Deletion task is ever deleted without being run, it is very hard to get the two environments truly synced as the full sync will only do insert/updates, not deletions.

Also the TaskName generation is rather poor for Node Binding tables, so It’s recommended if you do go this route, that you perhaps hook into the LogTask.Before and adjust the Task Name to be more descriptive:
 

private void NonBindingLogTask_Before(object sender, StagingLogTaskEventArgs e)
{
    // Create better named task
    if(e.Task.TaskObjectType == "cms.treecategory")
    {
        // The Task Data is an XML version of a DataSet, so convert to DataSet, then we can add our table data.
        string DataSetXML = e.Task.TaskData;
        DataSet DocumentDataSet = new DataSet();
        DocumentDataSet.ReadXml(new StringReader(DataSetXML));
        DataTable TreeCategoryTable = DocumentDataSet.Tables[0];
        TreeNode Node = new DocumentQuery().WhereEquals("NodeID", ValidationHelper.GetInteger(TreeCategoryTable.Rows[0]["NodeID"], -1)).Columns("NodeAliasPath").FirstObject;
        CategoryInfo Category = CategoryInfoProvider.GetCategories().WhereEquals("CategoryID", ValidationHelper.GetInteger(TreeCategoryTable.Rows[0]["CategoryID"], -1)).Columns("CategoryDisplayName").FirstObject;
        e.Task.TaskTitle = string.Format("{0} Category \"{1}\" {2} Node \"{3}\"", (e.Task.TaskType == TaskTypeEnum.CreateObject ? "Adding" : "Removing"), Category.CategoryDisplayName, (e.Task.TaskType == TaskTypeEnum.CreateObject ? "to" : "from"), Node.NodeAliasPath);
    }
}
 

Binding to the Parent (“TouchParent”)

If you wish, however, to have your Binding task attach to the parent, there are many benefits to this.  Firstly, synchronizing the document itself will always include the Relationships, which means if you do a full sync on a page, it will properly handle your relationships once configured, which is awesome.  It is also how most of the Node-bound relationships act in Kentico (if you add a Page relationship, it triggers an update, if you add a Document Category, it triggers an update on the document, etc).

To set this up on your BindingObjectTypeInfo, you would do the following:

new ObjectTypeInfo(typeof(TreeCategoryInfoProvider), OBJECT_TYPE, "CMS.TreeCategory", "TreeCategoryID", null, null, null, null, null, null, "NodeID", "cms.node")
{
	IsBinding = true,
	DependsOn = new List<ObjectDependency>()
	{
	    new ObjectDependency("CategoryID", "cms.category", ObjectDependencyEnum.Binding),
	}, SynchronizationSettings =
	{
    // Logging is handled separately
    LogSynchronization = SynchronizationTypeEnum.None
	},
	...
}

Now you may say “Wait…you said SynchronizationSetting to TouchParent, why is it set to None?”  Great question, and that’s because the Node Task generation is extremely complex, and as I mentioned, doesn’t work by “default,” so we must manually handle the synchronization ourselves, including triggering. 

Pro Tip: Allow for Both Methods

You can allow your binding objects to be handled both ways.  If you check out the TreeCategoryInfo.cs class in the zipped up code sample at the beginning of the article, you’ll see that the class’s ObjectTypeInfo is actually dynamically generated, and depending on a Settings value, will either processes with the Document or stand alone tasks.  The only thing is both environments must have the same configuration, and you need to restart the site and clear the cache for any changes to take place.

How Staging Works

Now before we dive into how we manually handle the Synchronization, let me go over how the Staging Task system works.  Feel free to skip to the actual implementation if you don’t care.

Triggering

First step in the Staging Module is triggering the staging event.  If Staging is turned on, and the object that is created, updated, or deleted has staging enabled, whenever that action occurs (usually with the Object.Insert(), Object.Update(), or better the MyObjectInfoProvider.SetMyObject(Object)), Kentico will trigger the creation of a Staging Task.

Building the Staging Task Data – Related Data Objects

The next step in the processes is to create the Task Data object, which needs to include any related objects, and the lookup values for these objects.  Most binding tables are ParentID – ChildID tables, two integers referencing the two different objects.  However, these IDs are the auto generated Row IDs, which means that when you go from one environment to another, the IDs can change and thus a mechanism needs to exist in order to translate one environment’s ID to the others.

This is done through object translation.  Most objects have an ID, CodeName, and/or GUID.  Kentico knows these fields thanks to the Object’s TypeInfo class, which defines which table columns contain these values.  For example, a Node has a NodeID, NodeAliasPath (Code Name), and NodeGUID, along with a SiteID for site-bound objects.

When Kentico is packaging up these relationships, it not only needs to include the actual relationship IDs, but also those bound object’s ID – Code Name – Guid, so the other environment can translate the IDs coming to it into what it’s equivalent IDs actually are.

Building the Staging Task Data – DataSet Creation

Now we know what we need (all the object data plus related objects and their Codename/Guid), how do we package this up?  Kentico uses a simple trick:  Combine all the DataTables with all the data into a DataSet, then serialize that into XML (DataSet.ToXML()).  It stores this in the TaskData field of the Task, which then can be leveraged on the receiving server and processed.

Now building these Tables would be a pain, luckily Kentico has a nice helper method to make our lives easier: SynchronizationHelper.GetObjectsData

This method takes a couple parameters and automatically returns a DataSet with all the Tables that are needed for the relationship (The binding object and the bound object’s identifier data).  This will come into play as I go through the actual code further on in this article.

Consuming the Task Data

When a staging task is pushed from one environment to the other, the receiving server will take the Serialized DataSet in the TaskData, and deserialize it, and use this to translate and handle the relationships, adding or removing any bound items and updating anything that may need to occur.  The processes can be broken down however into these steps:

  1. Grab the Binding Object Data Table
  2. Grab the Bound Object’s Data Table (contains a mapping of ID to CodeName and/or GUID)
  3. Translate the IDs from the binding table using the Bound Object’s Codename/Guid
  4. Add any missing binding relationships, and remove any existing relationships that no longer appear.

Step 3 is helped by a method called TranslationHelper.GetIDFromDB which automatically finds the proper ID given the GetIDParameters (Codename/Guid/SiteID) and the Object Type

Manually Appending Node Binding Data to the Node

As mentioned, Nodes are unique in that a lot of these steps must be done manually.  Here’s the full set of steps with Code:

Step 1: Trigger a DocumentUpdate When a Binding Occurs

The first step is whenever one of our Binding Items are insert / deleted / updated, we need to trigger an Update Document on the Node.  We will use the helper method DocumentSynchronizationHelper.LogDocumentChange to trigger this.

// This is done in your OnInit for a Module class
protected override void OnInit()
{
	base.OnInit();
	TreeCategoryInfo.TYPEINFO.Events.Insert.After += TreeCategory_Insert_Or_Delete_After;
	TreeCategoryInfo.TYPEINFO.Events.Delete.After += TreeCategory_Insert_Or_Delete_After;
}

private void TreeCategory_Insert_Or_Delete_After(object sender, ObjectEventArgs e)
{
	// Trigger update on document
	TreeNode Node = new DocumentQuery()
		.WhereEquals("NodeID", ((TreeCategoryInfo)e.Object).NodeID).FirstObject;
		DocumentSynchronizationHelper.LogDocumentChange(Node, TaskTypeEnum.UpdateDocument, Node.TreeProvider);
}
 

This will now trigger a document update when the relationship is added/removed.

Step 2: Appending th eBinding Table and Bound Object Data to the DataSet

The next step is we need to manually append our Binding DataTable and Bound Tables to the TaskData.
  1. Deserialize the TaskData’s DataSet back into an actual DataSet.
  2. Use SynchronizationHelper.GetObjectsData to get a DataSet of our Binding Data and its related info
  3. Serialize the DataSet to XML and then Deserialize back to DataSet, this will ensure the DataSet’s Columns are all type “String” which the TaskData’s DataSet will also have (the column types all need to match for Step 4)
  4. Use DataHelper.TransferTables to Merge your new Binding DataSet with the Deserialized TaskData’s DataSet
  5. Set the TaskData to the serialized new combined DataSet.
Here’s the code:

// This is done in your OnInit for a Module class
protected override void OnInit()
{
	StagingEvents.LogTask.Before += LogTask_Before;
}

private void LogTask_Before(object sender, StagingLogTaskEventArgs e)
{
    if(ValidationHelper.GetInteger(e.Task.TaskDocumentID, 0) > 1 && e.Task.TaskType == TaskTypeEnum.UpdateDocument)
    {
        TreeNode Node = new DocumentQuery().WhereEquals("DocumentID", e.Task.TaskDocumentID).FirstObject;
 
        // The Task Data is an XML version of a DataSet, so convert to DataSet, then we can add our table data.
        string DataSetXML = e.Task.TaskData;
        DataSet DocumentDataSet = new DataSet();
        DocumentDataSet.ReadXml(new StringReader(DataSetXML));
 
        // Now add your extra table data.
        // Get the related objects
        TreeCategoryInfo treeCategoryInfo = new TreeCategoryInfo();
        DataSet TreeNodeObjectData = SynchronizationHelper.GetObjectsData(OperationTypeEnum.Synchronization, treeCategoryInfo, string.Concat("NodeID = ", Node.NodeID), null, true, false, null);
        // Convert TreeNodeObjectData to XML and Back, this makes the Columns all type string so the transfer table works
        DataSet TreeNodeObjectDataHolder = new DataSet();
        TreeNodeObjectDataHolder.ReadXml(new StringReader(TreeNodeObjectData.GetXml()));
        if (!DataHelper.DataSourceIsEmpty(TreeNodeObjectDataHolder) && TreeNodeObjectDataHolder.Tables.Count > 0)
        {
            DataHelper.TransferTables(DocumentDataSet, TreeNodeObjectDataHolder);
        }

        // Convert it back to XML
        DataSetXML = DocumentDataSet.GetXml();
        e.Task.TaskData = DataSetXML;
    }
}

Step 3: Manually processes the Binding Table Data

The last step is to manually processes the Binding relationship on the other end.  This consists of:
  1. Catch the TaskAfter event, and wrap your logic in a “Synchronziation Off” Context so your updates you processes won’t trigger staging tasks themselves.
  2. Get the current Node and Bindings (so you know what needs to be added, removed or updated)
  3. Desierlaize the TaskData into a DataSet, and search for a Table with the name that matches your binding table’s tablename (ex “cms_treecategory”)
  4. Get all the Binding Object’s IDs from the table
  5. Find the table with your related object by name (ex “cms_category”) and use it to translate your Binding Object’s IDs
  6. Add, Remove, or Update any binding relationships that need to be added/removed now that you have the proper ID.
// This is done in your OnInit for a Module class
protected override void OnInit()
{
	// In the OnInit of the Module Class
	StagingEvents.ProcessTask.After += ProcessTask_After;
}

private void ProcessTask_After(object sender, StagingSynchronizationEventArgs e)
{
    if (e.TaskType == TaskTypeEnum.UpdateDocument)
    {
        // Get the Tree Category table.
        DataTable TreeCategoryTable = e.TaskData.Tables.Cast<DataTable>().Where(x => x.TableName.ToLower() == "cms_treecategory").FirstOrDefault();
 
        // Seems the first table is always the node's table, the table name dose change by the document page type.
        DataTable NodeTable = e.TaskData.Tables[0];
        if (TreeCategoryTable != null && NodeTable != null && NodeTable.Columns.Contains("NodeGuid"))
        {
            // Don't want to trigger updates as we set the data in the database, so we won't log synchronziations
                    using (new CMSActionContext()
            {
                LogSynchronization = false,
                LogIntegration = false
            })
            {
                // Get node ID
                        TreeNode NodeObj = new DocumentQuery().WhereEquals("NodeGUID", NodeTable.Rows[0]["NodeGuid"]).FirstObject;
                if(NodeObj == null)
                {
                    EventLogProvider.LogEvent("E", "RelationshipExended", "No Node Found", eventDescription: "No Node with the given GUID found, could not processes.");
                    return;
                }
 
                // Build translation from Category data
                List<int> NewNodeCategoryIDs = new List<int>();
                List<int> NodeCategoryIDs = TreeCategoryTable.Rows.Cast<DataRow>().Select(x => ValidationHelper.GetInteger(x["CategoryID"], 0)).ToList();
 
                NodeCategoryIDs.RemoveAll(x => x <= 0);
 
                // Go through the category tables which we'll use to gather the CategoryName, Guid, and SiteID to translate the IDs from old env to new.
                foreach(DataTable CategoryTable in e.TaskData.Tables.Cast<DataTable>().Where(x => x.TableName.ToLower() == "cms_category"))
                {
                    foreach(DataRow CategoryDR in CategoryTable.Rows)
                    {
                        int CategoryID = ValidationHelper.GetInteger(CategoryDR["CategoryID"], 0);
                        if(NodeCategoryIDs.Contains(CategoryID))
                        {
                            GetIDParameters CategoryParams = new GetIDParameters()
                            {
                                Guid = (Guid)CategoryDR["CategoryGUID"],
                                CodeName = (string)CategoryDR["CategoryName"]
                            };
                            if(CategoryTable.Columns.Contains("CategorySiteID") && ValidationHelper.GetInteger(CategoryDR["CategorySiteID"], 0) > 0)
                            {
                                CategoryParams.SiteId = ValidationHelper.GetInteger(CategoryDR["CategorySiteID"], 0);
                            }
                            try
                            {
                                int NewID = TranslationHelper.GetIDFromDB(CategoryParams, CategoryInfo.OBJECT_TYPE);
                                if(NewID > 0) {
                                    NewNodeCategoryIDs.Add(NewID);
                                }
                            } catch(Exception ex)
                            {
                                EventLogProvider.LogException("RelationshipExended", "No Category Found", ex, additionalMessage: "No Category could be found in the new system that matched the incoming Tree Category's Category.");
                            }
                        }
                    }
                }
 
                // Now handle categories, deleting categories not found, and adding ones that are not set yet.
                TreeCategoryInfoProvider.GetTreeCategories().WhereEquals("NodeID", NodeObj.NodeID).WhereNotIn("CategoryID", NewNodeCategoryIDs).ForEachObject(x => x.Delete());
                List<int> CurrentCategories = TreeCategoryInfoProvider.GetTreeCategories().WhereEquals("NodeID", NodeObj.NodeID).Select(x => x.CategoryID).ToList();
                foreach(int NewCategoryID in NewNodeCategoryIDs.Except(CurrentCategories))
                {
                    TreeCategoryInfoProvider.AddTreeToCategory(NodeObj.NodeID, NewCategoryID);
                }
            }
        } else if (NodeTable != null && NodeTable.Columns.Contains("NodeGuid"))
        {
            EventLogProvider.LogEvent("E", "RelationshipExended", "No Node Table Found", eventDescription: "First Table in the incoming Staging Task did not contain the Node GUID, could not processes.");
        }
    }
}

Conclusion

While this may seem like a lot of work, binding objects to the Node is a very safe and effective way to create relationships with things other than other Pages, and thanks to some leg work from yours truly, should be easy to replicate, allowing you to extend Kentico like a pro.  Keep an eye out for the Relationship Extended module which will have some User Interfaces to handle Node to Object binding (with order support) coming up, the default Object Binding UI template doesn’t allow Node binding.  If you want an early copy, just contact me!
 
Comments
Blog post currently doesn't have any comments.
= three - six
CONTENTEND