118 lines
4.5 KiB
C#
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);
|
|
}
|
|
}
|