Sometimes, you may find yourself in need of dealing with Hierarchical Tree Form data. In simple words, this is data presented in parent-child nodes.
In such situations, you might struggle with the complexity of the implementation, especially when dealing with a large amount of data.
In this article, I will provide you with one of the possible designs for such cases. However, you need to keep in mind that, as usual, having a generic design can offer abstracted capabilities, but it might lack specific enhancements for certain scenarios.
Therefore, I recommend treating the design presented in this article as a mind opener. You should study it, learn from it, gain ideas from it, and ultimately adapt it to your own needs.
These are the main goals of this design:
Therefore, while moving across our design, we need to keep in mind these requirements.
First, we need to keep in mind that the data itself could be any business entity. However, the Hierarchical structure is something else. This is the structure controlling the relation between nodes, how to add, how to remove, how to search,… and so on.
namespace HierarchicalData.Abstractions
{
public abstract class Payload
{
}
}
What we can notice here:
namespace HierarchicalData.Abstractions
{
public enum NodeType
{
Structural = 1,
Leaf = 2
}
}
What we can notice here:
namespace HierarchicalData.Abstractions
{
public interface INode
{
string Id { get; }
string Name { get; }
string PathPart { get; }
NodeType Type { get; }
IStructuralNode Parent { get; set; }
}
}
What we can notice here:
Id
is the unique identifier of the node. This should be absolute unique. I implemented it as a string but for sure you are free to change it as per your needs.Name
is the name of the node. This should at some point be used for the presentation layer.PathPart
is the name to be used to represent the path of the node.Parent
is the parent node. Its type is IStructuralNode
as it could not be a Leaf because a Leaf would never have a child.
using System.Collections.Generic;
namespace HierarchicalData.Abstractions
{
public delegate void ChildNodeAddedEventHandler(object sender, IStructuralNode node, INode added);
public delegate void ChildNodeRemovedEventHandler(object sender, IStructuralNode node, INode removed);
public interface IStructuralNode : INode
{
event ChildNodeAddedEventHandler ChildNodeAdded;
event ChildNodeRemovedEventHandler ChildNodeRemoved;
IReadOnlyCollection<INode> Children { get; }
new NodeType Type => NodeType.Structural;
void AddChild(INode child);
void RemoveChild(INode child);
IReadOnlyDictionary<string, INode> GetFlatChildren();
}
}
What we can notice here:
ChildNodeAddedEventHandler
to represent a handler of an event to be fired when a node is added as a child under another node.ChildNodeRemovedEventHandler
to represent a handler of an event to be fired when a node is removed from the children of another node.IStructuralNode
interface which extends INode
.ChildNodeAdded
and ChildNodeRemoved
.
namespace HierarchicalData.Abstractions
{
public interface ILeafNode : INode
{
Payload Payload { get; }
new NodeType Type => NodeType.Leaf;
}
public interface ILeafNode<out TPayload> : ILeafNode where TPayload : Payload
{
new TPayload Payload { get; }
}
}
What we can notice here:
ILeafNode
is extending the INode
.Payload
property which returns the wrapped data of type Payload
.Type
and defaults it to Leaf.ILeafNode<out TPayload>
hides the parent Payload
and defines a typed one.
using System.Linq;
using HierarchicalData.Abstractions;
namespace HierarchicalData.Implementations
{
public abstract class Node : INode
{
protected Node(
string id,
string name,
string pathPart,
NodeType type,
IStructuralNode parent)
{
Id = id;
Name = name;
PathPart = pathPart;
Type = type;
Parent = parent;
}
public string Id { get; }
public string Name { get; }
public string PathPart { get; }
public NodeType Type { get; }
public IStructuralNode Parent
{
get => m_Parent;
set
{
if (m_Parent != value)
{
if (value != null && value.Children.All(c => c.Id != Id))
{
value.AddChild(this);
}
else if (value == null)
{
m_Parent.RemoveChild(this);
}
m_Parent = value;
}
}
}
}
}
What we can notice here:
INode
.IEquatable<Node>
.Parent
property. However, the implementation is delegated to the AddChild
and RemoveChild
methods.
using HierarchicalData.Abstractions;
namespace HierarchicalData.Implementations
{
public class LeafNode<TPayload> : Node, ILeafNode<TPayload> where TPayload : Payload
{
public LeafNode(
string id,
string name,
string pathPart,
IStructuralNode parent,
TPayload payload) : base(id, name, pathPart, NodeType.Leaf, parent)
{
Payload = payload;
parent?.AddChild(this);
}
Payload ILeafNode.Payload => Payload;
public TPayload Payload { get; }
}
}
What we can notice here:
Node
and implements ILeafNode<TPayload>
.AddChild
on the parent passing in this
.
using System.Collections.Generic;
using System.Linq;
using HierarchicalData.Abstractions;
namespace HierarchicalData.Implementations
{
public class StructuralNode : Node, IStructuralNode
{
private readonly List<INode> m_Children;
private readonly Dictionary<string, INode> m_Catalog;
public event ChildNodeAddedEventHandler ChildNodeAdded;
public event ChildNodeRemovedEventHandler ChildNodeRemoved;
public StructuralNode(
string id,
string name,
string pathPart,
IStructuralNode parent = null,
List<INode> children = null) : base(id, name, pathPart, NodeType.Structural, parent)
{
m_Children = new List<INode>();
m_Catalog = new Dictionary<string, INode>();
if (children != null && children.Any())
{
foreach (var child in children)
{
AddChild(child);
}
}
parent?.AddChild(this);
}
public IReadOnlyCollection<INode> Children => m_Children;
public void AddChild(INode child)
{
if (child != null && Children.All(c => c.Id != child.Id))
{
child.Parent?.RemoveChild(child);
m_Catalog.Add(child.Id, child);
m_Children.Add(child);
OnDirectChildNodeAdded(child);
if (child.Type == NodeType.Structural && child is IStructuralNode structuralNode)
{
foreach (var keyValuePair in structuralNode.GetFlatChildren())
{
m_Catalog.Add(keyValuePair.Key, keyValuePair.Value);
OnNestedChildNodeAdded(keyValuePair.Value.Parent, keyValuePair.Value);
}
structuralNode.ChildNodeAdded += AddHandler;
structuralNode.ChildNodeRemoved += RemoveHandler;
}
child.Parent = this;
}
}
public void RemoveChild(INode child)
{
if (child != null && Children.Any(c => c.Id == child.Id))
{
m_Catalog.Remove(child.Id);
m_Children.Remove(child);
OnDirectChildNodeRemoved(child);
if (child.Type == NodeType.Structural && child is IStructuralNode structuralNode)
{
foreach (var keyValuePair in structuralNode.GetFlatChildren())
{
m_Catalog.Remove(keyValuePair.Key);
OnNestedChildNodeRemoved(keyValuePair.Value.Parent, keyValuePair.Value);
}
structuralNode.ChildNodeAdded -= AddHandler;
structuralNode.ChildNodeRemoved -= RemoveHandler;
}
child.Parent = null;
}
}
public IReadOnlyDictionary<string, INode> GetFlatChildren()
{
return m_Catalog;
}
protected void OnDirectChildNodeAdded(INode added)
{
ChildNodeAdded?.Invoke(this, this, added);
}
protected void OnNestedChildNodeAdded(IStructuralNode node, INode added)
{
ChildNodeAdded?.Invoke(this, node, added);
}
protected void OnDirectChildNodeRemoved(INode removed)
{
ChildNodeRemoved?.Invoke(this, this, removed);
}
protected void OnNestedChildNodeRemoved(IStructuralNode node, INode removed)
{
ChildNodeRemoved?.Invoke(this, node, removed);
}
private void AddHandler(object sender, IStructuralNode node, INode added)
{
m_Catalog.Add(added.Id, added);
OnNestedChildNodeAdded(node, added);
}
private void RemoveHandler(object sender, IStructuralNode node, INode removed)
{
m_Catalog.Remove(removed.Id);
OnNestedChildNodeRemoved(node, removed);
}
}
}
Before analyzing this code, we need to understand how it is going to work.
As we said, nodes should be able to be created in isolation. This means that the end user should be able to create a node without setting any parent or even children.
Then he should be able to attach this node as a child to another node. Also, he should be able to attach other nodes as children to the children of this node,… and so on.
For all these actions, our code should keep the structure and relations between nodes intact.
Also, we want to make it easy to search for nodes. A special case would be searching by Id. This should be as fast as we can.
Therefore, keeping this in mind, let’s analyze the code.
What we can notice here:
AddChild
method passing in each child of the children.AddChild
on the parent passing in this
.Dictionary<string, INode> m_Catalog
. This is a catalog where we keep references to all the nodes -even the nested ones- below the current node.ChildNodeAdded
and ChildNodeRemoved
events of every direct child nodes at the moment they are added.ChildNodeAdded
and ChildNodeRemoved
events of every direct child nodes at the moment they are removed.AddChild(INode child)
.child.Parent?.RemoveChild(child);
makes sure that the new child is detached from its old parent before attaching it to the new parent which is the current node.m_Catalog.Add(child.Id, child);
adds the new child to the catalog.m_Children.Add(child);
adds the child to the Children
collection.OnDirectChildNodeAdded(child);
fires the ChildNodeAdded
event to notify the direct parent that a new child is added and eventually new updates should be applied to its catalog.ChildNodeAdded
event but this time with the proper values of node
and added
arguments. This is done to notify the direct parent with the nested updates as well. Note that the direct parent, would also do the same and notify its direct parent,… and so on.ChildNodeAdded
and ChildNodeRemoved
events of the new child so that it is always updated. This is what we do on structuralNode.ChildNodeAdded += AddHandler;
and structuralNode.ChildNodeRemoved += RemoveHandler;
.child.Parent = this;
sets the parent of the child node to the current node.RemoveChild(INode child)
.m_Catalog.Remove(child.Id);
removes the child from the catalog.m_Children.Remove(child);
removes the child from the Children
collection.OnDirectChildNodeRemoved(child);
fires the ChildNodeRemoved
event to notify the direct parent that a child is removed and eventually new updates should be applied to its catalog.ChildNodeRemoved
event but this time with the proper values of node
and added
arguments. This is done to notify the direct parent with the nested updates as well. Note that the direct parent, would also do the same and notify its direct parent,… and so on.ChildNodeAdded
and ChildNodeRemoved
events of the child to be removed. This is what we do on structuralNode.ChildNodeAdded -= AddHandler;
and structuralNode.ChildNodeRemoved -= RemoveHandler;
.child.Parent = null;
resets the parent to null.I know that this part was tricky, but, if you just go through it again, you will find it not that complicated.
using System;
using System.Collections.Generic;
using System.Linq;
using HierarchicalData.Abstractions;
namespace HierarchicalData.Implementations
{
public class TreeNavigator
{
public string[] GetNodeFullPath(INode node)
{
var result = new List<string> { node.PathPart };
var parent = node.Parent;
while (parent != null)
{
result.Insert(0, parent.PathPart);
parent = parent.Parent;
}
return result.ToArray();
}
public INode SearchById(IStructuralNode root, string id)
{
if (root?.GetFlatChildren().ContainsKey(id) ?? false)
{
return root.GetFlatChildren()[id];
}
return null;
}
public IList<INode> Search(IStructuralNode root, Func<INode, bool> predicate)
{
var flat = root?.GetFlatChildren();
if (flat != null)
{
return flat
.Where(entry => predicate(entry.Value))
.Select(entry => entry.Value)
.ToList();
}
return new List<INode>();
}
public int FindNodeDepth(IStructuralNode root, INode node)
{
var foundNode = SearchById(root, node.Id);
if (foundNode != null)
{
var depth = 0;
var parent = node.Parent;
while (parent != null)
{
depth++;
if (parent.Id == root.Id)
{
break;
}
parent = parent.Parent;
}
return depth;
}
return 0;
}
}
}
This is like a Helper class which provides extra capabilities like searching the nodes, evaluating full paths, finding node depth,… and so on.
Internally, it is making a good use of the well defined and established structure we defined on our nodes classes.
I didn’t abstract this class but for sure you can do so and you can extend it with as many capabilities as you need.
I was going to write a sample application to show you how efficient our design is, but, after a second thought, I decided to leave this to you as an exercise.
So, I know that when you look at this design you might feel that it is too hard to understand. However, you need to keep in mind how complex the job is in the first place.
As I said at the beginning, you need to use the design in this article as a mind opener, give it some thought, learn from it, drop what you don’t need, adapt it, enhance it, and finally start using it. This would always be the right way to learn and gain new skills.
That’s it, hope you found reading this article as interesting as I found writing it.
Also published here.