fix(template-engine): resolve TemplateEngine-011,013,014 — remove dead converter, duplicate-id-safe cycle detection, unified deletion logic; TemplateEngine-012 deferred
This commit is contained in:
@@ -27,7 +27,10 @@ public static class CollisionDetector
|
||||
Template template,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
// Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
|
||||
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
|
||||
// not-yet-saved templates carrying Id 0).
|
||||
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||
var allMembers = new List<ResolvedMember>();
|
||||
|
||||
// Collect direct (top-level) members
|
||||
|
||||
@@ -9,6 +9,21 @@ namespace ScadaLink.TemplateEngine;
|
||||
/// </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.
|
||||
@@ -27,28 +42,30 @@ public static class CycleDetector
|
||||
|
||||
// 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 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);
|
||||
|
||||
var currentId = parentId;
|
||||
while (currentId != 0)
|
||||
// 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, out var current))
|
||||
if (!lookup.TryGetValue(currentId.Value, out var current))
|
||||
break;
|
||||
|
||||
chain.Add(current.Name);
|
||||
|
||||
if (visited.Contains(currentId))
|
||||
if (visited.Contains(currentId.Value))
|
||||
{
|
||||
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
|
||||
}
|
||||
|
||||
visited.Add(currentId);
|
||||
currentId = current.ParentTemplateId ?? 0;
|
||||
visited.Add(currentId.Value);
|
||||
currentId = current.ParentTemplateId;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -70,7 +87,7 @@ public static class CycleDetector
|
||||
return $"Template '{selfName}' cannot compose itself.";
|
||||
}
|
||||
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
var lookup = BuildLookup(allTemplates);
|
||||
|
||||
// BFS/DFS from composedTemplateId through all its compositions.
|
||||
// If we reach templateId, that's a cycle.
|
||||
@@ -115,7 +132,7 @@ public static class CycleDetector
|
||||
int? proposedComposedTemplateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
var lookup = BuildLookup(allTemplates);
|
||||
|
||||
// Build adjacency: for each template, collect all reachable templates
|
||||
// via inheritance (parent) and composition edges.
|
||||
@@ -124,11 +141,12 @@ public static class CycleDetector
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
|
||||
// Seed with proposed targets
|
||||
if (proposedParentId.HasValue && proposedParentId.Value != 0)
|
||||
// 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 && proposedComposedTemplateId.Value != 0)
|
||||
if (proposedComposedTemplateId.HasValue)
|
||||
queue.Enqueue(proposedComposedTemplateId.Value);
|
||||
|
||||
while (queue.Count > 0)
|
||||
@@ -146,8 +164,8 @@ public static class CycleDetector
|
||||
if (!lookup.TryGetValue(currentId, out var current))
|
||||
continue;
|
||||
|
||||
// Follow inheritance edge
|
||||
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0)
|
||||
// Follow inheritance edge (int? — missing value means no parent)
|
||||
if (current.ParentTemplateId.HasValue)
|
||||
queue.Enqueue(current.ParentTemplateId.Value);
|
||||
|
||||
// Follow composition edges
|
||||
|
||||
@@ -8,19 +8,24 @@ namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||
/// Same content always produces the same hash across runs by using
|
||||
/// canonical JSON serialization with sorted keys.
|
||||
/// Same content always produces the same hash across runs.
|
||||
/// <para>
|
||||
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR
|
||||
/// declaration order, which it does NOT sort. Stable output therefore relies
|
||||
/// on the private <c>Hashable*</c> records below declaring their properties
|
||||
/// in alphabetical order. A contributor adding a property out of alphabetical
|
||||
/// order would silently change every revision hash; the ordering is guarded
|
||||
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
||||
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class RevisionHashService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
// Sort properties alphabetically for determinism
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
// Ensure consistent ordering
|
||||
Converters = { new SortedPropertiesConverterFactory() }
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -84,7 +89,9 @@ public class RevisionHashService
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
// Internal types for deterministic serialization (sorted property names via camelCase + alphabetical)
|
||||
// Internal types for deterministic serialization. Properties MUST be declared
|
||||
// in alphabetical order — System.Text.Json emits them in declaration order and
|
||||
// does not sort. See the DETERMINISM CONTRACT note on the class summary.
|
||||
private sealed record HashableConfiguration
|
||||
{
|
||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||
@@ -128,14 +135,3 @@ public class RevisionHashService
|
||||
public string? TriggerType { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A JSON converter factory that ensures properties are serialized in alphabetical order
|
||||
/// for deterministic output. Works with record types.
|
||||
/// </summary>
|
||||
internal class SortedPropertiesConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert) => false;
|
||||
|
||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ public static class TemplateResolver
|
||||
int templateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
// Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
|
||||
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
|
||||
// not-yet-saved templates carrying Id 0).
|
||||
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||
if (!lookup.TryGetValue(templateId, out var template))
|
||||
return Array.Empty<ResolvedTemplateMember>();
|
||||
|
||||
|
||||
@@ -112,59 +112,18 @@ public class TemplateService
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Derived templates are owned by their composition row and must be removed
|
||||
// by deleting the composition (which cascades) — block direct deletion.
|
||||
if (template.IsDerived)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||
"Remove the owning composition on its parent template instead.");
|
||||
// Deletion-constraint logic (instances / child / derived / composing
|
||||
// templates) lives in exactly one place — TemplateDeletionService — so a
|
||||
// future rule change cannot drift between two implementations
|
||||
// (TemplateEngine-014). TemplateService owns only the audit-logging side
|
||||
// effect, which the deletion service is unaware of.
|
||||
var deletionService = new Services.TemplateDeletionService(_repository);
|
||||
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
if (deleteResult.IsFailure)
|
||||
return deleteResult;
|
||||
|
||||
// Check for instances referencing this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
||||
|
||||
// Check for child templates inheriting from this template.
|
||||
// Split derived vs. regular children — the message and remediation differ.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||
|
||||
if (regularChildren.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " +
|
||||
string.Join(", ", regularChildren.Select(c => $"'{c.Name}'")));
|
||||
|
||||
if (derivatives.Count > 0)
|
||||
{
|
||||
// Name each derivative by its owning parent template + composition slot.
|
||||
var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||
var ownerLookup = allTemplates
|
||||
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||
.Where(x => ownerCompIds.Contains(x.Composition.Id))
|
||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||
|
||||
var details = derivatives
|
||||
.Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||
? label
|
||||
: $"'{d.Name}'");
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " +
|
||||
string.Join(", ", details) + ". Remove those compositions first.");
|
||||
}
|
||||
|
||||
// Check for templates composing this template
|
||||
var composedBy = allTemplates
|
||||
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId))
|
||||
.ToList();
|
||||
if (composedBy.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is composed by {composedBy.Count} template(s): " +
|
||||
string.Join(", ", composedBy.Select(c => $"'{c.Name}'")));
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
// TemplateDeletionService already persisted the delete; the audit entry
|
||||
// is added to the change tracker here and needs its own SaveChangesAsync.
|
||||
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user