181 lines
6.7 KiB
C#
181 lines
6.7 KiB
C#
using ScadaLink.Commons.Entities.Templates;
|
|
|
|
namespace ScadaLink.TemplateEngine;
|
|
|
|
/// <summary>
|
|
/// Detects cycles in template inheritance and composition graphs.
|
|
/// Covers: self-inheritance, circular inheritance chains, self-composition,
|
|
/// circular composition chains, and cross-graph (inheritance + composition) cycles.
|
|
/// </summary>
|
|
public static class CycleDetector
|
|
{
|
|
/// <summary>
|
|
/// Builds an Id-keyed lookup that tolerates duplicate Ids in the input list
|
|
/// (e.g. multiple not-yet-saved templates all carrying Id 0). On a duplicate
|
|
/// the first occurrence wins — graph walks only need one representative node
|
|
/// per Id, and a real cycle through any duplicate would still be reachable.
|
|
/// A plain <c>ToDictionary(t => t.Id)</c> would instead throw ArgumentException.
|
|
/// </summary>
|
|
internal static Dictionary<int, Template> BuildLookup(IReadOnlyList<Template> allTemplates)
|
|
{
|
|
var lookup = new Dictionary<int, Template>();
|
|
foreach (var t in allTemplates)
|
|
lookup.TryAdd(t.Id, t);
|
|
return lookup;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether setting <paramref name="parentId"/> as the parent of template
|
|
/// <paramref name="templateId"/> would introduce an inheritance cycle.
|
|
/// </summary>
|
|
/// <returns>A description of the cycle if one would be created, or null if safe.</returns>
|
|
public static string? DetectInheritanceCycle(
|
|
int templateId,
|
|
int parentId,
|
|
IReadOnlyList<Template> allTemplates)
|
|
{
|
|
if (templateId == parentId)
|
|
{
|
|
var selfName = allTemplates.FirstOrDefault(t => t.Id == templateId)?.Name ?? templateId.ToString();
|
|
return $"Template '{selfName}' cannot inherit from itself.";
|
|
}
|
|
|
|
// Walk the inheritance chain from the proposed parent upward.
|
|
// If we arrive back at templateId, there is a cycle.
|
|
var lookup = BuildLookup(allTemplates);
|
|
var visited = new HashSet<int> { templateId };
|
|
var chain = new List<string>();
|
|
|
|
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
|
|
chain.Add(templateName);
|
|
|
|
// ParentTemplateId is int? — a missing value (not 0) means "no parent",
|
|
// so a template with a real Id of 0 is walked like any other node.
|
|
int? currentId = parentId;
|
|
while (currentId.HasValue)
|
|
{
|
|
if (!lookup.TryGetValue(currentId.Value, out var current))
|
|
break;
|
|
|
|
chain.Add(current.Name);
|
|
|
|
if (visited.Contains(currentId.Value))
|
|
{
|
|
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
|
|
}
|
|
|
|
visited.Add(currentId.Value);
|
|
currentId = current.ParentTemplateId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether adding a composition of <paramref name="composedTemplateId"/> into
|
|
/// <paramref name="templateId"/> would introduce a composition cycle.
|
|
/// </summary>
|
|
/// <returns>A description of the cycle if one would be created, or null if safe.</returns>
|
|
public static string? DetectCompositionCycle(
|
|
int templateId,
|
|
int composedTemplateId,
|
|
IReadOnlyList<Template> allTemplates)
|
|
{
|
|
if (templateId == composedTemplateId)
|
|
{
|
|
var selfName = allTemplates.FirstOrDefault(t => t.Id == templateId)?.Name ?? templateId.ToString();
|
|
return $"Template '{selfName}' cannot compose itself.";
|
|
}
|
|
|
|
var lookup = BuildLookup(allTemplates);
|
|
|
|
// BFS/DFS from composedTemplateId through all its compositions.
|
|
// If we reach templateId, that's a cycle.
|
|
var visited = new HashSet<int>();
|
|
var queue = new Queue<int>();
|
|
queue.Enqueue(composedTemplateId);
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var currentId = queue.Dequeue();
|
|
if (currentId == templateId)
|
|
{
|
|
var tmplName = lookup.TryGetValue(templateId, out var t1) ? t1.Name : templateId.ToString();
|
|
var composedName = lookup.TryGetValue(composedTemplateId, out var t2) ? t2.Name : composedTemplateId.ToString();
|
|
return $"Composition cycle detected: '{tmplName}' -> '{composedName}' -> ... -> '{tmplName}'.";
|
|
}
|
|
|
|
if (!visited.Add(currentId))
|
|
continue;
|
|
|
|
if (!lookup.TryGetValue(currentId, out var current))
|
|
continue;
|
|
|
|
foreach (var comp in current.Compositions)
|
|
{
|
|
queue.Enqueue(comp.ComposedTemplateId);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detects cross-graph cycles that span both inheritance and composition edges.
|
|
/// A cross-graph cycle exists when following any combination of inheritance (parent)
|
|
/// and composition edges from a template leads back to itself.
|
|
/// </summary>
|
|
/// <returns>A description of the cycle if found, or null if safe.</returns>
|
|
public static string? DetectCrossGraphCycle(
|
|
int templateId,
|
|
int? proposedParentId,
|
|
int? proposedComposedTemplateId,
|
|
IReadOnlyList<Template> allTemplates)
|
|
{
|
|
var lookup = BuildLookup(allTemplates);
|
|
|
|
// Build adjacency: for each template, collect all reachable templates
|
|
// via inheritance (parent) and composition edges.
|
|
// We temporarily add the proposed edge and check for reachability back to templateId.
|
|
|
|
var visited = new HashSet<int>();
|
|
var queue = new Queue<int>();
|
|
|
|
// Seed with proposed targets. A null proposed id means "no edge"; a value
|
|
// of 0 is a legitimate Id, so only HasValue gates enqueuing.
|
|
if (proposedParentId.HasValue)
|
|
queue.Enqueue(proposedParentId.Value);
|
|
|
|
if (proposedComposedTemplateId.HasValue)
|
|
queue.Enqueue(proposedComposedTemplateId.Value);
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var currentId = queue.Dequeue();
|
|
if (currentId == templateId)
|
|
{
|
|
var tmplName = lookup.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
|
|
return $"Cross-graph cycle detected involving template '{tmplName}'.";
|
|
}
|
|
|
|
if (!visited.Add(currentId))
|
|
continue;
|
|
|
|
if (!lookup.TryGetValue(currentId, out var current))
|
|
continue;
|
|
|
|
// Follow inheritance edge (int? — missing value means no parent)
|
|
if (current.ParentTemplateId.HasValue)
|
|
queue.Enqueue(current.ParentTemplateId.Value);
|
|
|
|
// Follow composition edges
|
|
foreach (var comp in current.Compositions)
|
|
{
|
|
queue.Enqueue(comp.ComposedTemplateId);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|