Files
Distribution_GL/Helpers/RecursiveTreeQueries.cs

118 lines
4.5 KiB
C#

namespace General_Ledger_Service.Repositories
{
// Shared tree logic for every self-referencing entity (Account, CostCenter — see
// 01_Full_Development_Plan.md Section 4.2). LINQ/EF Core cannot express a recursive CTE, so the
// calling repository loads the entity's rows once as TreeRow (a plain id/code/name/parent
// projection) and these methods walk that in-memory list. Pure functions: no DbContext, no SQL,
// so the same code works on any database provider. This is a shared static helper, not a
// generic repository base class (CLAUDE.md Rule 2) — each repository keeps its own public
// interface, method signatures, and result DTOs; only the tree-walking is shared.
internal static class RecursiveTreeQueries
{
internal class TreeRow
{
public long Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; }
public int Depth { get; set; }
}
// Depth a new child of parentId would sit at: the number of nodes from parentId up to its
// root, plus one. A parentId that matches no row yields 1.
public static int GetAncestorDepth(IReadOnlyList<TreeRow> rows, long parentId)
{
var byId = rows.ToDictionary(r => r.Id);
var count = 0;
var visited = new HashSet<long>();
long? current = parentId;
while (current.HasValue && byId.TryGetValue(current.Value, out var node) && visited.Add(node.Id))
{
count++;
current = node.ParentId;
}
return count + 1;
}
// "Root > ... > Entity" path of names, or null if the entity does not exist.
public static string? GetAncestorBreadcrumb(IReadOnlyList<TreeRow> rows, long entityId)
{
var byId = rows.ToDictionary(r => r.Id);
var names = new List<string>();
var visited = new HashSet<long>();
long? current = entityId;
while (current.HasValue && byId.TryGetValue(current.Value, out var node) && visited.Add(node.Id))
{
names.Add(node.Name);
current = node.ParentId;
}
if (names.Count == 0)
{
return null;
}
names.Reverse();
return string.Join(" > ", names);
}
// The entity plus every descendant, depth 1 for the entity itself, ordered by depth then code.
public static List<TreeRow> GetDescendantTree(IReadOnlyList<TreeRow> rows, long entityId)
{
var childrenByParent = rows
.Where(r => r.ParentId.HasValue)
.ToLookup(r => r.ParentId!.Value);
var root = rows.FirstOrDefault(r => r.Id == entityId);
if (root is null)
{
return [];
}
var result = new List<TreeRow>();
var visited = new HashSet<long>();
var queue = new Queue<(TreeRow Node, int Depth)>();
queue.Enqueue((root, 1));
while (queue.Count > 0)
{
var (node, depth) = queue.Dequeue();
if (!visited.Add(node.Id))
{
continue;
}
result.Add(new TreeRow
{
Id = node.Id,
Code = node.Code,
Name = node.Name,
ParentId = node.ParentId,
Depth = depth
});
foreach (var child in childrenByParent[node.Id])
{
queue.Enqueue((child, depth + 1));
}
}
return [.. result
.OrderBy(r => r.Depth)
.ThenBy(r => r.Code, StringComparer.Ordinal)];
}
// Ids of the entity and all of its descendants; empty if the entity does not exist.
public static HashSet<long> GetDescendantIds(IReadOnlyList<TreeRow> rows, long entityId) =>
[.. GetDescendantTree(rows, entityId).Select(r => r.Id)];
// True if newParentId is the entity itself or one of its descendants, i.e. making it the
// entity's parent would create a loop.
public static bool WouldCreateCycle(IReadOnlyList<TreeRow> rows, long entityId, long newParentId) =>
GetDescendantIds(rows, entityId).Contains(newParentId);
}
}