INode Interface and ToTree Methods - lobodava/artisan-orm GitHub Wiki

Hierarchical data — folder trees, organisation charts, comment threads, BOMs — is naturally stored in SQL as a flat table with Id / ParentId (or via hierarchyid). Artisan.Orm has built-in support for reading such tables and re-assembling them into a tree of objects in one step.

Background reading. This page is the API reference. For the long-form story of the Id-ParentId + hierarchyid combined approach — including the schema design, the recursive CTE, and the trade-offs — see the re-published article: Combination of Id-ParentId and HierarchyId Approaches to Hierarchical Data.

The INode<T> contract

public interface INode<T> where T : class
{
    int       Id        { get; set; }
    int?      ParentId  { get; set; }
    IList<T>? Children  { get; set; }
}

Any class that exposes these three members can be turned into a tree by Artisan.Orm. ParentId == null marks a root.

Example: Folder tree

public class Folder : INode<Folder>
{
    public int        Id        { get; set; }
    public int?       ParentId  { get; set; }
    public string     Name      { get; set; } = null!;
    public IList<Folder>? Children { get; set; }
}
create procedure dbo.GetFolderTree
    @RootFolderId int
as
begin
    set nocount on;

    with cte as
    (
        select Id, ParentId, [Name], 0 as Lvl
        from   dbo.Folders
        where  Id = @RootFolderId

        union all

        select f.Id, f.ParentId, f.[Name], cte.Lvl + 1
        from   dbo.Folders  f
        inner join cte on cte.Id = f.ParentId
    )
    select Id, ParentId, [Name]
    from   cte
    order by Lvl, ParentId, Id;     -- pre-order traversal
end;
public Folder? GetFolderTree(int rootFolderId)
{
    return GetByCommand(cmd =>
    {
        cmd.UseProcedure("dbo.GetFolderTree");
        cmd.AddIntParam("@RootFolderId", rootFolderId);

        // hierarchicallySorted: true unlocks the faster single-pass build.
        return cmd.ReadToTree<Folder>(hierarchicallySorted: true);
    });
}

The result is a fully-linked tree where every node's Children is populated.

Available methods

Method Returns Use when
ReadToTree<T> T? (root) The query produces a single root node.
ReadToTreeList<T> IList<T> The query produces a forest — multiple roots.

Both come in three layers — base / command / reader — and have async siblings:

// RepositoryBase level
public T? ReadToTree<T>     (string sql, bool hierarchicallySorted = false, ...);
public IList<T> ReadToTreeList<T>(string sql, bool hierarchicallySorted = false, ...);
public Task<T?> ReadToTreeAsync<T>(...);
public Task<IList<T>> ReadToTreeListAsync<T>(...);

// SqlCommand level — when you need to set parameters via Action<SqlCommand>
public T? ReadToTree<T>(this SqlCommand cmd, bool hierarchicallySorted = false);

// SqlDataReader level — for reading a tree from a multi-result-set reader
public T? ReadToTree<T>(this SqlDataReader dr, bool getNextResult = true,
                                               bool hierarchicallySorted = false);

<T> must satisfy where T : class, INode<T>.

hierarchicallySorted parameter

  • false (default): the builder collects all nodes into a dictionary keyed by Id, then a second pass walks the dictionary linking each node to its parent's Children. Two passes, allocates a Dictionary<int, T>. Works with any row order.

  • true: the builder assumes rows arrive in pre-order traversal (parent before its children, siblings consecutive). It walks the list once as a stack — no dictionary, single pass. Faster, but the SQL must guarantee the order.

The pre-order requirement is satisfied by:

  • a recursive CTE with order by Lvl, ParentId, Id (as shown in the example above),
  • hierarchyid-based queries with order by Hid,
  • path-string columns with order by Path.

If the order isn't guaranteed, leave hierarchicallySorted at false.

Single root vs forest

ReadToTree<T> returns the first root found (or null for an empty result set). If your query may yield multiple top-level nodes — e.g., "all folders for several users" — use ReadToTreeList<T>:

// Returns every Folder whose ParentId is null, each with its full subtree.
IList<Folder> roots = repo.ReadToTreeList<Folder>("dbo.GetAllUserFolders");

ToTree() / ToTreeList() on a flat list

If you already have a flat IList<T> from somewhere — say, you read it once with ReadToList and want to build the tree separately — call the same algorithm directly:

IList<Folder> flat = repo.ReadToList<Folder>("dbo.GetAllFolders");

Folder? root = flat.ToTree(hierarchicallySorted: false);
// or
IList<Folder> forest = flat.ToTreeList(hierarchicallySorted: false);

This is helpful when you want to apply C# filters / transformations on the flat list first, then build the tree from the survivors.

Combining Id / ParentId with hierarchyid

For very deep or very wide hierarchies, the SQL Server hierarchyid type pairs nicely with INode<T>:

  • Keep Id / ParentId as the primary application contract (works in any DB engine, easy to debug).
  • Add a hierarchyid column for fast subtree queries (<descendant_of>, <>, range scans on a hierarchical index).
  • order by the hierarchyid column to get pre-order rows that satisfy hierarchicallySorted: true.

The full design — schema, indexing strategy, and how the two representations stay in sync — is covered in the re-published article: Combination of Id-ParentId and HierarchyId Approaches to Hierarchical Data.

Working example in the test suite

Tests/DAL/Folders/Repository.cs exercises the full set — GetFolderTree, FindFoldersWithParentTree, list-to-tree conversion utilities — against the seeded dbo.Folders table. Worth a read when you adopt this for a new entity.


See also:

⚠️ **GitHub.com Fallback** ⚠️