'SQL Server Hierarchy Rank Display

I would like to seek help regarding SQL Server query.

I have a table named Students, and columns are "Id, Name, LeaderId"

Id  Name    LeaderId
1   Baldo       0
2   Cardo       1
9   Luningning  1
6   Pedro       1
3   Paolo       2
5   Reginaldo   4
4   Bernardo    5
7   Roberto     6
8   Narciso     6
10  Jopay       9
11  Policarpio  10
12  Sisa        10

Example is, Baldo has no Leader, because he is on the very top on Hierarchy structure. Leader of Cardo, Luningning, Pedro is Baldo. Leader of Paolo is Cardo. Because the LeaderId of Paolo is 2.

Please see my sample screenshot below.

enter image description here

My problem is how to display the rankings of hierarchy structure based on the picture above.

Having hard times to do it, try grouping by leaderId, use CTE with self join etc. but I can't get the expected output.

Expected output on query

Name Rank
Baldo 0
Cardo 1
Luningning 1
Pedro 1

Paolo 2
Roberto 2
Narcisco 2
Jopay 2

and so on....

Thank you in advance.



Solution 1:[1]

Use a recursive CTE. Start with the top of the hierarchy, i.e. LeaderId = 0. Then join back to the CTE to get the subordinates of that leader, and so on.

WITH tree AS (
   SELECT Id, Name, LeaderId, CAST(0 AS INT) AS RankNum
   FROM   YourTable
   WHERE  LeaderId = 0
   UNION ALL
   SELECT c.Id, c.Name, c.LeaderId, RankNum+1
   FROM   YourTable c INNER JOIN tree 
            ON c.LeaderId = tree.Id
)
SELECT * 
FROM   tree
ORDER BY RankNum
GO

Results:

Id | Name       | LeaderId | RankNum
-: | :--------- | -------: | ------:
 1 | Baldo      |        0 |       0
 2 | Cardo      |        1 |       1
 9 | Luningning |        1 |       1
 6 | Pedro      |        1 |       1
 7 | Roberto    |        6 |       2
 8 | Narciso    |        6 |       2
10 | Jopay      |        9 |       2
 3 | Paolo      |        2 |       2
11 | Policarpio |       10 |       3
12 | Sisa       |       10 |       3

db<>fiddle here

Solution 2:[2]

Showing another way to do it with hierarhcyid. I'm stealing the recursive CTE from the answer that SOS provided merely as a convenience. I'll explain in a bit why that's not necessary and how your application can maintain the hierarchyid value directly.

with YourTable as (
    select * from (values 
        (1, 'Baldo',       0),
        (2, 'Cardo',       1),
        (9, 'Luningning',  1),
        (6, 'Pedro',       1),
        (3, 'Paolo',       2),
        (5, 'Reginaldo',   4),
        (4, 'Bernardo',    5),
        (7, 'Roberto',     6),
        (8, 'Narciso',     6),
        (10, 'Jopay',       9),
        (11, 'Policarpio',  10),
        (12, 'Sisa',        10)
    ) as x(Id, Name, LeaderId)
), tree AS (
    SELECT Id, Name, LeaderId, 
        h = cast(concat('/', Id, '/') as varchar(max)) 
    FROM   YourTable
    WHERE  LeaderId = 0

    UNION ALL

    SELECT c.Id, c.Name, c.LeaderId,
        h = concat(tree.h, c.Id, '/')
    FROM   YourTable c 
    INNER JOIN tree 
        ON c.LeaderId = tree.Id
), hid as (
    select Id, Name, LeaderId,
    h = cast(h as hierarchyid)
    from tree
)
SELECT *, 
    h.ToString(), 
    RankNum = h.GetLevel() - 1
FROM   hid
ORDER BY RankNum, h

By way of exposition for this answer, I'm calculating a value that can be cast as a hierarchyid. From there, I'm showing that you can get the desired RankNum information from the hierarchyid.

Now, why would you want to do this? In my example, I'm having to calculate the hierarchyid value at run time. But it can also be be maintained by your application as changes are made to the data! Let's look at the three types of data changes - inserts, updates, and deletes.

First, let's set up the table.

/*drop table if exists dbo.Employees;*/
create table dbo.Employees (
    EmployeeId int identity
        constraint PK_Employees primary key clustered,
    Name varchar(100) not null,
    LeaderId int null,
    ReportingStructure hierarchyid null
);
create index IX_Employees__ReportingStructure
    on dbo.Employees (ReportingStructure);
go

The only thing that's noteworthy here is that the hierarchyid data type is indexable. We'll use that fact later when updating and deleting employees. Next, let's handle creating new employee records:

create or alter procedure dbo.InsertEmployee (
    @Name varchar(100),
    @LeaderID int
)
as
begin
    set nocount on;
    declare @Id int, @ReportingStructure hierarchyid;
    declare @IdCatcher table (
        Id int
    );

    set @ReportingStructure = (
        select ReportingStructure
        from dbo.Employees
        where EmployeeId = @LeaderID
    );
    if (@ReportingStructure is null)
        set @ReportingStructure = '/';

    begin tran

        insert into dbo.Employees
            (Name, LeaderID)
        output inserted.EmployeeId
        into @IdCatcher
        values
            (@Name, @LeaderID);

        set @Id = (
            select Id
            from @IdCatcher
        );

        update dbo.Employees
        set ReportingStructure = concat(@ReportingStructure.ToString(), @Id, '/')
        where EmployeeId = @Id;

    commit
end
go

This is fairly straightforward. The steps are:

  • Find the ReportingStructure for the passed in Leader.
    • If none is found, they'll be starting their own tree and use their EmployeeID to seed it
  • Insert the new record and get the EmployeeID from it
  • Update the new record with the correct ReportingStructure.

Next we'll handle updates. Note, the only updates we're interested in for this exercise are updates to someone's manager. That is, changing someone's name doesn't affect their place in the org chart and so we can ignore it here.

create or alter procedure dbo.UpdateEmployeeLeader (
    @EmployeeID int,
    @NewLeaderID int
)
as
begin
    set nocount on;
    declare @newManagerReportingStructure hierarchyid,
        @oldReportingStructure hierarchyid;

    set @newManagerReportingStructure = (
        select ReportingStructure
        from dbo.Employees
        where EmployeeId = @NewLeaderID
    );
    set @oldReportingStructure = (
        select ReportingStructure
        from dbo.Employees
        where EmployeeId = @EmployeeID
    );

    if (@oldReportingStructure is not null and @newManagerReportingStructure is not null)
    begin
        begin tran
            update dbo.Employees
            set LeaderId = @NewLeaderID
            where EmployeeId = @EmployeeID;

            with cte as (
                select ReportingStructure,
                    NewReportingStructure = 
                    ReportingStructure.GetReparentedValue(
                        @oldReportingStructure.GetAncestor(1),
                        @newManagerReportingStructure
                    )
                from dbo.Employees
                where ReportingStructure.IsDescendantOf(@oldReportingStructure) = 1
            )
            update cte
            set ReportingStructure = NewReportingStructure
        commit tran
    end
end
go

The plain language explanation of what's happening here is:

  • Find the ReportingStructure for the new manager and the employee being moved
  • Update the requested employee as well as all employees that report to them (directly or indirectly) to reflect the new reporting structure.

That last bit I think warrants some explanation. The "all employees that report to them (directly or indirectly)" is accomplished by the ReportingStructure.IsDescendantOf(@oldReportingStructure) = 1 bit of code. As a note, it also identifies the Employee themselves (as IsDescendantOf() considers values to be descendants of themselves). Also noteworthy is that the call to IsDescendantOf() is efficient because of that index that we created on the ReportingStructure column.

The "to reflect the new reporting structure" bit is accomplished via the GetReparentedValue() bit of code. I like to think of what's happening there as a string replacement of some leading part of the path with a new leading part. Extending the tree metaphor, it's like you're taking a branch from one tree and grafting it onto another tree.

Lastly, let's deal with deletes.

create or alter procedure dbo.DeleteEmployee (
    @EmployeeID int
)
as
begin
    set nocount on;
    declare @ReportingStructure hierarchyid = (
        select ReportingStructure
        from dbo.Employees
        where EmployeeId = @EmployeeID
    );
    if not exists (
        select 1
        from dbo.Employees
        where ReportingStructure.IsDescendantOf(@ReportingStructure) = 1
            and EmployeeId <> @EmployeeID
    )
        delete dbo.Employees
        where EmployeeId = @EmployeeID;
    else
    begin
        raiserror(
            'Employee still has direct reports. Update their leader before deleting.',
            16,
            1
        );
    end
end
go

This is pretty straightforward. We're just checking to see that this employee has any reports (direct or otherwise, though the previous update procedure should ensure that we don't have any data that "skips" a level) before deleting.

To wrap it all up, here are calls to these procs that deal with operations within your data. Let's hire some employees!

exec dbo.InsertEmployee @name = 'Baldo', @LeaderID = 0;
exec dbo.InsertEmployee @name = 'Cardo', @LeaderID = 1;
exec dbo.InsertEmployee @name = 'Luningning', @LeaderID = 1;
exec dbo.InsertEmployee @name = 'Pedro', @LeaderID = 1;
exec dbo.InsertEmployee @name = 'Paolo', @LeaderID = 2;
exec dbo.InsertEmployee @name = 'Reginaldo', @LeaderID = 4;
exec dbo.InsertEmployee @name = 'Bernardo', @LeaderID = 5;
exec dbo.InsertEmployee @name = 'Roberto', @LeaderID = 6;
exec dbo.InsertEmployee @name = 'Narciso', @LeaderID = 6;
exec dbo.InsertEmployee @name = 'Jopay', @LeaderID = 9;
exec dbo.InsertEmployee @name = 'Policarpio', @LeaderID = 10;
exec dbo.InsertEmployee @name = 'Sisa', @LeaderID = 10;

Bad news... Reginaldo just quit. Let's update Narciso's manager to be Paolo

exec dbo.UpdateEmployeeLeader @EmployeeID = 9, @NewLeaderID = 5;
exec dbo.DeleteEmployee @EmployeeID = 6;

Oops... we missed Roberto! Let's have him also report to Paolo for now.

exec dbo.UpdateEmployeeLeader @EmployeeID = 8, @NewLeaderID = 5;
exec dbo.DeleteEmployee @EmployeeID = 6;

The last thing I'll say about this approach is that I still like having LeaderID be a column in the table. Why? I would consider that to be the source of truth for "who reports to whom?" and the ReportingStructure column is merely a materialized path from leaf to root in the tree. If something happens where ReportingStructure gets corrupted (i.e. doesn't accurately reflect the reporting structure), we can recalculate it from the LeaderID/EmployeeID links.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 SOS
Solution 2 Ben Thul