Files
scadalink-design/src/ScadaLink.TemplateEngine/CycleDetector.cs
Joseph Doherty faef2d0de6 Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity
- WP-23: ITemplateEngineRepository full EF Core implementation
- WP-1: Template CRUD with deletion constraints (instances, children, compositions)
- WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity
- WP-5: Shared script CRUD with syntax validation
- WP-6–7: Composition with recursive nesting and canonical naming
- WP-8–11: Override granularity, locking rules, inheritance/composition scope
- WP-12: Naming collision detection on canonical names (recursive)
- WP-13: Graph acyclicity (inheritance + composition cycles)
Core services: TemplateService, SharedScriptService, TemplateResolver,
LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
2026-03-16 20:10:34 -04:00

163 lines
5.9 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>
/// 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 = allTemplates.ToDictionary(t => t.Id);
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);
var currentId = parentId;
while (currentId != 0)
{
if (!lookup.TryGetValue(currentId, out var current))
break;
chain.Add(current.Name);
if (visited.Contains(currentId))
{
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
}
visited.Add(currentId);
currentId = current.ParentTemplateId ?? 0;
}
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 = allTemplates.ToDictionary(t => t.Id);
// 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 = allTemplates.ToDictionary(t => t.Id);
// 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
if (proposedParentId.HasValue && proposedParentId.Value != 0)
queue.Enqueue(proposedParentId.Value);
if (proposedComposedTemplateId.HasValue && proposedComposedTemplateId.Value != 0)
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
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0)
queue.Enqueue(current.ParentTemplateId.Value);
// Follow composition edges
foreach (var comp in current.Compositions)
{
queue.Enqueue(comp.ComposedTemplateId);
}
}
return null;
}
}