Introduction to Hierarchical and alphabetical sorting

To sort the data hierarchically and alphabetically means sorting a tree using depth-first search algorithm and selecting nodes with the same parent in the alphabetical order of their names.

The hierarchical sorting along with the level of nesting is a good way to emulate an expanded tree or a table of a book's contents using just a flat list of objects without the necessity to build a tree of objects.

Just fetch the records, sort them hierachically and alphabetically, indent each record n times ( where n is the level of nesting), and you'll get the expanded tree for UI and reports.

Two approaches

There are several approaches to structure a relational database to store the hierarchical data. The one, which is being used more frequently, is the Id-ParentId approach.

The Id-ParentId approach has a few drawbacks that make it necessary to seek alternative methods of storing hierarchical data.

The most difficult and expensive tasks in Id-ParentId approach are those which require recursive methods. For example -

As a response to these difficulties, Microsoft added the HierarchyId data type to SQL Server since version 2008.

The HierarchyId data type,

If you want to build a clustered index on HierarchyId, then all the node-records will physically be stored in the order in which they are most frequently fetched.

But the HierarchyId data type still has drawbacks, which are the trade-offs for its useful properties,

So what to choose: Id-ParentId or HierarchyId?

An acceptable solution to this dilemma of choice may be the combination of two approaches: Id-ParentId and HierarchyId.

This combination will allow us to,

This article describes the way to unite the both approaches and ensure their normal operation.

Some Facts about HierarchyId

Column and field names in this article and source code,

  • Hid — column of HierarchyId data type
  • HidPath — string column for human-readable path of HierarchyId
  • HidCode — a binary presentation of HierarchyId converted to a string and without leading 0x.

To human-readable format

The HierarchyId data type is a materialized path encoded into binary format. It can be decoded to human-readable string path with ToString() method,

  1. select Hid, Hid.ToString() as HidPath, Hid.GetLevel() as Level from Folders order by Hid;

  2. -- Note: ToString() and GetLevel() are case sensitive!!!
Results----------------------------------- Hid HidPath Level ---------- ------------- -------- 0x / 0
0x58 /1/ 1
0x5AC0 /1/1/ 2
0x5AD6 /1/1/1/ 3
0x5AD6B0 /1/1/1/1/ 4
0x5AD6B580 /1/1/1/1/1/ 5
0x5AD6B680 /1/1/1/1/2/ 5
0x5AD6B780 /1/1/1/1/3/ 5
0x5AD6D0 /1/1/1/2/ 4
0x5AD6D580 /1/1/1/2/1/ 5
0x5AD6D680 /1/1/1/2/2/ 5
0x5AD6D780 /1/1/1/2/3/ 5
...

From human-readable format

The opposite operation from string path to binary format works also.

  1. declare @Hid hierarchyid = '/1/1/1/1/3/';
  2. select @Hid

Results
------------
0x5AD6B780

The above is the implicit conversion. HierarchyId has the explicit Parse() method, although a call format is a little strange.

  1. declare @Hid hierarchyid = hierarchyid::Parse('/1/1/1/1/3/');
  2. -- Parse() is case sensitive!!!
  3. select @Hid

Results
------------
0x5AD6B780

Tricky numbering system of paths

The interesting thing about the HierarchyId is the way how it manages the numeration of a node inserted between two other nodes.

To get a Hid for a new node, HierarchyId uses GetDescendant() method from parent Hid and uses two Hids of nodes between which the new has to be inserted.

  1. declare @Parent hierarchyid = 0x;
  2. print @Parent.GetDescendant('/1/', '/2/').ToString() -- /1.1/
  3. print @Parent.GetDescendant('/1/', '/1.1/').ToString() -- /1.0/
  4. print @Parent.GetDescendant('/1/', '/1.0/').ToString() -- /1.-1/
  5. print @Parent.GetDescendant('/1/', '/1.-1/').ToString() -- /1.-2/
  6. print @Parent.GetDescendant('/1.1/', '/2/').ToString() -- /1.2/
  7. print @Parent.GetDescendant('/1.2/', '/2/').ToString() -- /1.3/
  8. print @Parent.GetDescendant('/1.3/', '/2/').ToString() -- /1.4/
  9. print @Parent.GetDescendant('/1.3/', '/1.4/').ToString() -- /1.3.1/
  10. print @Parent.GetDescendant('/1.2.3.4.5.6.7.8/', '/1.2.3.4.5.6.7.9/').ToString()
    -- /1.2.3.4.5.6.7.8.1/
  11. -- by the way
  12. declare @Hid hierarchyid = '/1.2.3.4.5.6.7.8.1/';
  13. select @Hid; -- 0x63A08A49A85258
  14. declare @Hid hierarchyid = '/-1.-2.-3.-4.-5.-6.-7.-8.-1234567890/';
  15. select @Hid; -- 0x41F8F87A3C1D8E87216D9A81A73A
  16. -- special cases with null
  17. print @Parent.GetDescendant(null, null).ToString() -- /1/
  18. print @Parent.GetDescendant('/1/', null).ToString() -- /2/
  19. print @Parent.GetDescendant(null, '/1/').ToString() -- /0/

Why do we need binary encoding?

If we can build a string path, why do we need this HierarchyId binary encoding?

Could we just sort the hierarchical data by this string path?

Look at this example,

  1. select hierarchyid::Parse('/1/') as Hid, '/1/' as HidPath union all
  2. select hierarchyid::Parse('/2/') , '/2/' union all
  3. select hierarchyid::Parse('/10/') , '/10/'
  4. order by HidPath;

Results
--------------
Hid HidPath
----- --------
0x58 /1/
0xAA /10/
0x68 /2/

The same query but ordered by Hid makes the right sorting:

Results
--------------
Hid HidPath ----- -------- 0x58 /1/ 0x68 /2/ 0xAA /10/

Microsoft uses the sophisticated algorithm of HierarchyId to encode the string path so that it can be used for hierarchical sorting just by the Hid column value.

The Solution

Master and slave

So, let's combine Id-ParentId self referencing approach with HierarchyId.

The Id-ParentId part will be responsible for Id primary key, self-reference ParentId foreign key and be the leading, or master. The HierarchyId part will be a calculated field, or slave.

Of course, Hid as a persistent calculated field is the denormalization. But this is a conscious step and a compromise for the advantages of HierarchyId.

The best place and moment to calculate the HierarchyId and keep it in coordinated state is a stored procedure and while saving the node.

User Catalog for experiments

Let's imagine we have a multi-user application where each of user keeps its own Catalog. This Catalog keeps information in hierarchical Folders.

The table for these Folders might look like this,

  1. create table dbo.Folders
  2. (
  3. UserId int not null ,
  4. Hid hierarchyid not null ,
  5. Id int not null identity,
  6. ParentId int null ,
  7. Name nvarchar(50) not null ,
  8. constraint CU_Folders unique clustered (UserId asc, Hid asc),
  9. constraint PK_Folders primary key nonclustered (Id asc),
  10. constraint FK_Folders_UserId foreign key (UserId) references dbo.Users (Id),
  11. constraint FK_Folders_ParentId foreign key (ParentId) references dbo.Folders (Id)
  12. constraint CH_Folders_ParentId check (Hid = 0x and ParentId is null or Hid <> 0x and ParentId is not null)
  13. );

Note, that the clustered index is build on UserId and Hid, but the primary key is build on Id column.

This allows to keep all the records of the user's folders hierarchically sorted physically. And, at the same time, use the integer primary key as a foreign key for another tables and DTOs.

It is well known, that the hierarchy built on HierarchyId may have one root only.

Because the clustered key is the combination of UserId and Hid columns, a table dbo.Folders may have one root for each user.

The root node is mandatory, every user's catalog must have one, so the root is mostly a system record that is not edited by users.

Once the root node is a system record, every other nodes must have a parent and ParentId must be not null.

The best place to make calculation

As Microsoft states it here: "It is up to the application to generate and assign HierarchyId values in such a way that the desired relationship between rows is reflected in the values."

The best way to create and keep integrity and consistency of hierarchical data in our case is always use the same stored procedure to insert and update a Folder node.

A user-defined table type that represents saving Folder may serve as a parameter to the SaveFolder stored procedure,

  1. create type dbo.FolderTableType as table
  2. (
  3. Id int not null primary key clustered,
  4. ParentId int not null ,
  5. Name nvarchar(50) not null
  6. );

And here is the SaveFolder stored procedure itself,

  1. create procedure dbo.SaveFolder
  2. @Folder dbo.FolderTableType readonly
  3. as
  4. begin
  5. set nocount on;
  6. begin -- variable declaration
  7. declare
  8. @ParamId int ,
  9. @ParentId int ,
  10. @UserId int ,
  11. @ParentHid hierarchyid ,
  12. @StartTranCount int ,
  13. @OldHid hierarchyid ,
  14. @NewHid hierarchyid ;
  15. declare @FolderIds table (
  16. InsertedId int not null,
  17. OldHid hierarchyid null,
  18. NewHid hierarchyid null
  19. );
  20. end;
  21. begin try
  22. set @StartTranCount = @@trancount;
  23. if @StartTranCount = 0
  24. begin
  25. set transaction isolation level serializable;
  26. begin transaction;
  27. end;
  28. begin -- init variables and lock parent for update
  29. select
  30. @ParamId = Id,
  31. @ParentId = ParentId
  32. from
  33. @Folder;
  34. select
  35. @UserId = UserId,
  36. @ParentHid = Hid
  37. from
  38. dbo.Folders
  39. where
  40. Id = @ParentId;
  41. end;
  42. begin -- save the @Folder
  43. merge into dbo.Folders as target
  44. using
  45. (
  46. -- full join in this 'select' allows to see picture as if
  47. -- the change already applied,
  48. -- thus coalesce(t.Id, f.Id) take new value
  49. -- if t.Id exists and old value if not
  50. select
  51. -- LAG and LEAD functions help to find the previous
  52. -- and next Hid values if to sort by Name
  53. Hid = @ParentHid.GetDescendant (
  54. LAG (case when t.Id is null then f.Hid end)
  55. over(order by coalesce(t.Name, f.Name)),
  56. LEAD(case when t.Id is null then f.Hid end)
  57. over(order by coalesce(t.Name, f.Name))
  58. ),
  59. Id = coalesce( t.Id , f.Id ),
  60. ParentId = coalesce( t.ParentId , f.ParentId ),
  61. Name = coalesce( t.Name , f.Name )
  62. from
  63. (select * from dbo.Folders where ParentId = @ParentId) f
  64. full join @Folder t on t.Id = f.Id
  65. )
  66. -- for LAG and LEAD functions we need all children of the @ParentId
  67. -- but for merge we need the Folder where source.Id = @ParamId
  68. as source on source.Id = @ParamId and source.Id = target.Id
  69. -- target.UserId = @UserId here just to make sure
  70. -- that we do not reparent a Folder to another user
  71. when matched and target.UserId = @UserId then
  72. update set
  73. Hid = source.Hid ,
  74. Name = source.Name ,
  75. ParentId = source.ParentId
  76. -- source.Id = 0 here just to make sure that we insert a new Folder
  77. -- and not already deleted one
  78. -- not matched Id can be deleted by another user and we can try to add it again
  79. when not matched by target and source.Id = 0 then
  80. insert (
  81. UserId ,
  82. Hid ,
  83. ParentId ,
  84. Name )
  85. values (
  86. @UserId ,
  87. source.Hid ,
  88. source.ParentId ,
  89. source.Name )
  90. output
  91. inserted.Id ,
  92. deleted.Hid ,
  93. inserted.Hid
  94. into @FolderIds (
  95. InsertedId ,
  96. OldHid ,
  97. NewHid );
  98. end;
  99. begin -- reparent children
  100. -- saving folder might change its ParentId
  101. -- in that case Hid of all its children should reflect the change also
  102. select top 1 @OldHid = OldHid, @NewHid = NewHid from @FolderIds;
  103. if @OldHid <> @NewHid
  104. update dbo.Folders set
  105. Hid = Hid.GetReparentedValue(@OldHid, @NewHid)
  106. where
  107. UserId = @UserId
  108. and Hid.IsDescendantOf(@OldHid) = 1;
  109. end;
  110. if @StartTranCount = 0 commit transaction;
  111. end try
  112. begin catch
  113. if xact_state() <> 0 and @StartTranCount = 0 rollback transaction;
  114. declare @ErrorMessage nvarchar(4000) = dbo.GetErrorMessage();
  115. raiserror (@ErrorMessage, 16, 1);
  116. return;
  117. end catch;
  118. end;

If you want to save each Folder by the above stored procedure only, then the Hid column will always have actual and consistent information.

Fetch a Folder with its descendants in hierarchical order

Thus the following stored procedure fetches a Folder with all its descendants and sorts them hierarchically and then alphabetically as if they are an expanded tree,

  1. create procedure dbo.GetFolderWithSubFolders
  2. @FolderId int
  3. as
  4. begin
  5. set nocount on;
  6. select
  7. d.Id ,
  8. d.ParentId ,
  9. d.Name ,
  10. [Level] = d.Hid.GetLevel(),
  11. HidCode = dbo.GetHidCode(d.Hid),
  12. HidPath = d.Hid.ToString()
  13. from
  14. dbo.Folders f
  15. inner join dbo.Folders d on d.UserId = f.UserId and d.Hid.IsDescendantOf(f.Hid) = 1
  16. where
  17. f.Id = @FolderId
  18. order by
  19. d.Hid;
  20. end;

For example

  1. exec dbo.GetFolderWithSubFolders @FolderId = 40

Results ---------------------------------------------------- Id ParentId Name Level HidCode HidPath
----- --------- ----- ------ ---------- ------------
40 13 3A 3 5AD6 /1/1/1/
121 40 4A 4 5AD6B0 /1/1/1/1/
364 121 5A 5 5AD6B580 /1/1/1/1/1/
365 121 5B 5 5AD6B680 /1/1/1/1/2/
366 121 5C 5 5AD6B780 /1/1/1/1/3/
122 40 4B 4 5AD6D0 /1/1/1/2/
367 122 5A 5 5AD6D580 /1/1/1/2/1/
368 122 5B 5 5AD6D680 /1/1/1/2/2/
369 122 5C 5 5AD6D780 /1/1/1/2/3/
123 40 4C 4 5AD6F0 /1/1/1/3/
370 123 5A 5 5AD6F580 /1/1/1/3/1/
371 123 5B 5 5AD6F680 /1/1/1/3/2/
372 123 5C 5 5AD6F780 /1/1/1/3/3/

What is the use of HidCode?

As it stated above, HidCode is a binary presentation of HierarchyId converted to varchar and without leading 0x.

Here is the dbo.GetHidCode scalar SQL function,

  1. create function dbo.GetHidCode
  2. (
  3. @Hid hierarchyid
  4. )
  5. returns varchar(1000)
  6. as
  7. begin
  8. return replace(convert(varchar(1000), cast(@Hid as varbinary(892)), 2), '0x', '');
  9. end;

The HidCode string can be used on a client side to sort a flat list of Folders hierarchically.

Chapter for the perfectionists

The above method of Hid calculation works well, but it has a tiny drawback.

imagine you have two sibling folders
Id ParentId Name Level HidCode HidPath
----- --------- ----- ------ ---------- ------------
364 121 5A 5 5AD6B580 /1/1/1/1/1/
365 121 5B 5 5AD6B680 /1/1/1/1/2/
and you want to insert a new folder
Id ParentId Name
----- --------- -----
0 121 5Aaa
the result will be
Id ParentId Name Level HidCode HidPath
----- --------- ----- ------ ---------- --------------
364 121 5A 5 5AD6B580 /1/1/1/1/1/
1234 121 5Aaa 5 5AD6B62C /1/1/1/1/1.1/
365 121 5B 5 5AD6B680 /1/1/1/1/2/

See this sequence in HidPath: 1 -> 1.1 -> 2 instead of 1 -> 2 -> 3

In a time this ugly sequence may become more uglier.

If you OK with that then just skip this chapter.

But for the others I would suggest one more option for keeping the hierarchical data in actual state.

Here is the stored procedure which recalculates the HierarchyId so the path consists of integer numbers in a continuous sequence.

  1. create procedure dbo.SaveFolderWithHidReculc
  2. @Folder dbo.FolderTableType readonly
  3. as
  4. begin
  5. set nocount on;
  6. begin -- variable declaration
  7. declare
  8. @ParentId int ,
  9. @UserId int ,
  10. @ParentHid hierarchyid ,
  11. @ParentHidStr varchar(1000) ,
  12. @StartTranCount int ,
  13. @OldParentId int ,
  14. @OldParentHid hierarchyid ;
  15. declare @FolderIds table (
  16. InsertedId int not null,
  17. OldParentId int null,
  18. OldParentHid hierarchyid null
  19. );
  20. end;
  21. begin try
  22. set @StartTranCount = @@trancount;
  23. if @StartTranCount = 0
  24. begin
  25. set transaction isolation level serializable;
  26. begin transaction;
  27. end;
  28. begin -- init variables and lock parent for update
  29. select @ParentId = ParentId from @Folder;
  30. select
  31. @UserId = UserId ,
  32. @ParentHid = Hid ,
  33. @ParentHidStr = cast(Hid as varchar(1000))
  34. from
  35. dbo.Folders
  36. where
  37. Id = @ParentId;
  38. end;
  39. begin -- merge calculated hierarchical data with existing folders
  40. merge into dbo.Folders as target
  41. using
  42. (
  43. select
  44. Hid = cast(concat(@ParentHidStr, -1, '/') as varchar(1000)),
  45. Id ,
  46. ParentId ,
  47. Name
  48. from
  49. @Folder
  50. )
  51. as source on source.Id = target.Id
  52. when matched and target.UserId = @UserId then
  53. update set
  54. ParentId = source.ParentId ,
  55. Name = source.Name
  56. when not matched by target and source.Id = 0 then
  57. insert (
  58. UserId ,
  59. Hid ,
  60. ParentId ,
  61. Name )
  62. values (
  63. @UserId ,
  64. source.Hid ,
  65. source.ParentId ,
  66. source.Name )
  67. output
  68. inserted.Id,
  69. deleted.ParentId,
  70. deleted.Hid.GetAncestor(1)
  71. into
  72. @FolderIds (
  73. InsertedId ,
  74. OldParentId ,
  75. OldParentHid );
  76. end
  77. begin -- reculculate SubFolder Hids
  78. select top 1
  79. @OldParentId = OldParentId ,
  80. @OldParentHid = OldParentHid
  81. from
  82. @FolderIds;
  83. exec dbo.ReculcSubFolderHids @UserId, @ParentId, @ParentHid, @OldParentId, @OldParentHid ;
  84. end;
  85. if @StartTranCount = 0 commit transaction;
  86. end try
  87. begin catch
  88. if xact_state() <> 0 and @StartTranCount = 0 rollback transaction;
  89. declare @ErrorMessage nvarchar(4000) = dbo.GetErrorMessage();
  90. raiserror (@ErrorMessage, 16, 1);
  91. return;
  92. end catch;
  93. end;

The trick here is in call of ReculcSubFolderHids stored procedure after the @Folder save,

  1. create procedure dbo.ReculcSubFolderHids
  2. @UserId int ,
  3. @ParentId int ,
  4. @ParentHid hierarchyid ,
  5. @OldParentId int = null,
  6. @OldParentHid hierarchyid = null
  7. as
  8. begin
  9. declare @ParentHidStr varchar(1000) = cast(@ParentHid as varchar(1000));
  10. declare @OldParentHidStr varchar(1000) = cast(@OldParentId as varchar(1000));
  11. with Recursion as
  12. (
  13. select
  14. Id ,
  15. ParentId ,
  16. Name ,
  17. OldHid = cast(Hid as varchar(1000)),
  18. NewHid = cast(
  19. concat(
  20. case when ParentId = @ParentId then @ParentHidStr else @OldParentHidStr end,
  21. row_number() over (order by Name, Id),
  22. '/'
  23. )
  24. as varchar(1000)
  25. )
  26. from
  27. dbo.Folders
  28. where
  29. ParentId in (@ParentId, @OldParentId)
  30. union all
  31. select
  32. Id = f.Id ,
  33. ParentId = f.ParentId ,
  34. Name = f.Name ,
  35. OldHid = cast(f.Hid as varchar(1000)),
  36. NewHid = cast(
  37. concat(
  38. r.NewHid,
  39. row_number() over (partition by f.ParentId order by f.Name, f.Id),
  40. '/'
  41. )
  42. as varchar(1000)
  43. )
  44. from
  45. Recursion r
  46. inner join dbo.Folders f on f.ParentId = r.Id
  47. where
  48. r.OldHid <> r.NewHid
  49. )
  50. update f set
  51. Hid = r.NewHid
  52. from
  53. dbo.Folders f
  54. inner join Recursion r on r.Id = f.Id and f.Hid <> r.NewHid
  55. where
  56. f.UserId = @UserId;
  57. end;

The above stored procedure calculated the paths which consists of integer numbers in a continuous sequence.

So the result of the example in the section beginning with SaveFolderWithHidReculc will be

Id ParentId Name Level HidCode HidPath
----- --------- ----- ------ ---------- ------------
364 121 5A 5 5AD6B580 /1/1/1/1/1/
1234 121 5Aaa 5 5AD6B680 /1/1/1/1/2/
365 121 5B 5 5AD6B780 /1/1/1/1/3/

This method has a drawback also. It works well until the recalculated branch consists of up to about 10,000 nodes, when the time for calculation does not exceed a second. 100,000 nodes' recalculation may take up to 15 seconds or more.

Read a Tree in C#

The implemented Id-ParentId reference allows to build a tree of Folders in C#.

Moreover, once the Folders are sorted hierarchically, the tree can be built for one iteration through the flat list of Folders.

Suppose we have a Folder class in C#,

  1. public class Folder
  2. {
  3. public Int32 Id { get; set; }
  4. public Int32? ParentId { get; set; }
  5. public String Name { get; set; }
  6. public IList<Folder> SubFolders { get; set; }
  7. }

So, the following method builds a tree for one pass through the hierarchically sorted Folder list,

  1. public static IList<Folder> ConvertHierarchicallySortedFolderListToTrees(IEnumerable<Folder> folders)
  2. {
  3. var parentStack = new Stack<Folder>();
  4. var parent = default(Folder);
  5. var prevNode = default(Folder);
  6. var rootNodes = new List<Folder>();
  7. foreach (var folder in folders)
  8. {
  9. if (parent == null || folder.ParentId == null)
  10. {
  11. rootNodes.Add(folder);
  12. parent = folder;
  13. }
  14. else if (folder.ParentId == parent.Id)
  15. {
  16. if (parent.SubFolders == null)
  17. parent.SubFolders = new List<Folder>();
  18. parent.SubFolders.Add(folder);
  19. }
  20. else if (folder.ParentId == prevNode.Id)
  21. {
  22. parentStack.Push(parent);
  23. parent = prevNode;
  24. if (parent.SubFolders == null)
  25. parent.SubFolders = new List<Folder>();
  26. parent.SubFolders.Add(folder);
  27. }
  28. else
  29. {
  30. var parentFound = false;
  31. while(parentStack.Count > 0 && parentFound == false)
  32. {
  33. parent = parentStack.Pop();
  34. if (folder.ParentId != null && folder.ParentId.Value == parent.Id)
  35. {
  36. parent.SubFolders.Add(folder);
  37. parentFound = true;
  38. }
  39. }
  40. if (parentFound == false)
  41. {
  42. rootNodes.Add(folder);
  43. parent = folder;
  44. }
  45. }
  46. prevNode = folder;
  47. }
  48. return rootNodes;
  49. }

The above method works two times faster than the following method for an unsorted Folder list,

  1. public static IList<Folder> ConvertHierarchicallyUnsortedFolderListToTrees(IEnumerable<Folder> folders)
  2. {
  3. var dictionary = folders.ToDictionary(n => n.Id, n => n);
  4. var rootFolders = new List<Folder>();
  5. foreach (var folder in dictionary.Select(item => item.Value))
  6. {
  7. Folder parent;
  8. if (folder.ParentId.HasValue && dictionary.TryGetValue(folder.ParentId.Value, out parent))
  9. {
  10. if (parent.SubFolders == null)
  11. parent.SubFolders = new List<Folder>();
  12. parent.SubFolders.Add(folder);
  13. }
  14. else
  15. {
  16. rootFolders.Add(folder);
  17. }
  18. }
  19. return rootFolders;
  20. }

Both methods can build multiple roots, so the return type is IList<Folder>. That is useful when you need to fetch several bunches detached from the tree or several independent trees.

About source code

The attached archive contains the Hierarchy solution, created in Visual Studio 2015, which consists of two projects,

The solution contains the examples of,

In order to install the database and run the tests, change the connection string in file Hierarchy.DB.publish.xml and in App.config to yours.

The Database project contains the dbo.GenerateFolders stored procedure which generates hierarchical data for tests. This generation occurs automatically during the database publishing phase.