Files
scadalink-design/src/ScadaLink.TemplateEngine/CycleDetector.cs

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 =&gt; 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;
}
}