refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions across composed module members using canonical (path-qualified) names.
|
||||
/// Two members from different composed modules collide if they produce the same canonical name.
|
||||
/// Members from different module instance names cannot collide because the prefix differentiates them.
|
||||
/// </summary>
|
||||
public static class CollisionDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a resolved member with its canonical name and origin.
|
||||
/// </summary>
|
||||
public sealed record ResolvedMember(
|
||||
string CanonicalName,
|
||||
string MemberType, // "Attribute", "Alarm", "Script"
|
||||
string OriginDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions among all members (direct + composed) of a template.
|
||||
/// </summary>
|
||||
/// <param name="template">The template to check.</param>
|
||||
/// <param name="allTemplates">All templates in the system (for resolving composed templates).</param>
|
||||
/// <returns>List of collision descriptions. Empty if no collisions.</returns>
|
||||
public static IReadOnlyList<string> DetectCollisions(
|
||||
Template template,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
// 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
|
||||
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers);
|
||||
|
||||
// Collect members from composed modules recursively
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
prefix: composition.InstanceName,
|
||||
lookup,
|
||||
allMembers,
|
||||
visited: new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect inherited members (walk parent chain)
|
||||
CollectInheritedMembers(template, lookup, allMembers, new HashSet<int> { template.Id });
|
||||
|
||||
// Detect duplicates by canonical name
|
||||
var collisions = new List<string>();
|
||||
var grouped = allMembers.GroupBy(m => m.CanonicalName, StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var members = group.ToList();
|
||||
if (members.Count > 1)
|
||||
{
|
||||
// Only report collision if members come from different origins
|
||||
var distinctOrigins = members.Select(m => m.OriginDescription).Distinct().ToList();
|
||||
if (distinctOrigins.Count > 1)
|
||||
{
|
||||
var origins = string.Join(", ", members.Select(m => $"{m.MemberType} from {m.OriginDescription}"));
|
||||
collisions.Add($"Naming collision on '{group.Key}': {origins}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collisions;
|
||||
}
|
||||
|
||||
private static void CollectDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
string originPrefix,
|
||||
List<ResolvedMember> members)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Script", originPrefix));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectComposedMembers(
|
||||
Template template,
|
||||
string prefix,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
// Add direct members of this composed template with the prefix
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInheritedMembers(
|
||||
Template template,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!template.ParentTemplateId.HasValue)
|
||||
return;
|
||||
|
||||
if (!lookup.TryGetValue(template.ParentTemplateId.Value, out var parent))
|
||||
return;
|
||||
|
||||
if (!visited.Add(parent.Id))
|
||||
return;
|
||||
|
||||
// Inherited direct members (no prefix)
|
||||
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members);
|
||||
|
||||
// Inherited composed modules
|
||||
foreach (var composition in parent.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
composition.InstanceName,
|
||||
lookup,
|
||||
members,
|
||||
new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Continue up the inheritance chain
|
||||
CollectInheritedMembers(parent, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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>
|
||||
/// <param name="allTemplates">All templates to build lookup from.</param>
|
||||
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>
|
||||
/// <param name="templateId">The template being modified.</param>
|
||||
/// <param name="parentId">The proposed parent template ID.</param>
|
||||
/// <param name="allTemplates">All templates for cycle detection.</param>
|
||||
/// <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>
|
||||
/// <param name="templateId">The template being modified.</param>
|
||||
/// <param name="composedTemplateId">The template to compose.</param>
|
||||
/// <param name="allTemplates">All templates for cycle detection.</param>
|
||||
/// <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>
|
||||
/// <param name="templateId">The template being modified.</param>
|
||||
/// <param name="proposedParentId">Optional proposed parent template ID.</param>
|
||||
/// <param name="proposedComposedTemplateId">Optional proposed composition template ID.</param>
|
||||
/// <param name="allTemplates">All templates for cycle detection.</param>
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two FlattenedConfigurations (deployed vs current) and produces a ConfigurationDiff
|
||||
/// showing Added, Removed, and Changed entries for attributes, alarms, and scripts.
|
||||
/// </summary>
|
||||
public class DiffService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the diff between an old (deployed) and new (current) flattened configuration.
|
||||
/// </summary>
|
||||
/// <param name="oldConfig">The previously deployed configuration. Null for first-time deployment.</param>
|
||||
/// <param name="newConfig">The current flattened configuration.</param>
|
||||
/// <param name="oldRevisionHash">The revision hash of the old config, if any.</param>
|
||||
/// <param name="newRevisionHash">The revision hash of the new config.</param>
|
||||
/// <returns>A ConfigurationDiff with all changes.</returns>
|
||||
public ConfigurationDiff ComputeDiff(
|
||||
FlattenedConfiguration? oldConfig,
|
||||
FlattenedConfiguration newConfig,
|
||||
string? oldRevisionHash = null,
|
||||
string? newRevisionHash = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newConfig);
|
||||
|
||||
var attributeChanges = ComputeEntityDiff(
|
||||
oldConfig?.Attributes ?? [],
|
||||
newConfig.Attributes,
|
||||
a => a.CanonicalName,
|
||||
AttributesEqual);
|
||||
|
||||
var alarmChanges = ComputeEntityDiff(
|
||||
oldConfig?.Alarms ?? [],
|
||||
newConfig.Alarms,
|
||||
a => a.CanonicalName,
|
||||
AlarmsEqual);
|
||||
|
||||
var scriptChanges = ComputeEntityDiff(
|
||||
oldConfig?.Scripts ?? [],
|
||||
newConfig.Scripts,
|
||||
s => s.CanonicalName,
|
||||
ScriptsEqual);
|
||||
|
||||
return new ConfigurationDiff
|
||||
{
|
||||
InstanceUniqueName = newConfig.InstanceUniqueName,
|
||||
OldRevisionHash = oldRevisionHash,
|
||||
NewRevisionHash = newRevisionHash,
|
||||
AttributeChanges = attributeChanges,
|
||||
AlarmChanges = alarmChanges,
|
||||
ScriptChanges = scriptChanges
|
||||
};
|
||||
}
|
||||
|
||||
private static List<DiffEntry<T>> ComputeEntityDiff<T>(
|
||||
IReadOnlyList<T> oldItems,
|
||||
IReadOnlyList<T> newItems,
|
||||
Func<T, string> getCanonicalName,
|
||||
Func<T, T, bool> areEqual)
|
||||
{
|
||||
var result = new List<DiffEntry<T>>();
|
||||
|
||||
var oldMap = oldItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);
|
||||
var newMap = newItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);
|
||||
|
||||
// Find removed and changed
|
||||
foreach (var (name, oldItem) in oldMap)
|
||||
{
|
||||
if (!newMap.TryGetValue(name, out var newItem))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Removed,
|
||||
OldValue = oldItem,
|
||||
NewValue = default
|
||||
});
|
||||
}
|
||||
else if (!areEqual(oldItem, newItem))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Changed,
|
||||
OldValue = oldItem,
|
||||
NewValue = newItem
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find added
|
||||
foreach (var (name, newItem) in newMap)
|
||||
{
|
||||
if (!oldMap.ContainsKey(name))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Added,
|
||||
OldValue = default,
|
||||
NewValue = newItem
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(d => d.CanonicalName, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(ResolvedAttribute a, ResolvedAttribute b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Value == b.Value &&
|
||||
a.DataType == b.DataType &&
|
||||
a.Description == b.Description &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.DataSourceReference == b.DataSourceReference &&
|
||||
a.BoundDataConnectionId == b.BoundDataConnectionId;
|
||||
|
||||
private static bool AlarmsEqual(ResolvedAlarm a, ResolvedAlarm b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Description == b.Description &&
|
||||
a.PriorityLevel == b.PriorityLevel &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.TriggerType == b.TriggerType &&
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.OnTriggerScriptCanonicalName == b.OnTriggerScriptCanonicalName;
|
||||
|
||||
private static bool ScriptsEqual(ResolvedScript a, ResolvedScript b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Code == b.Code &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.TriggerType == b.TriggerType &&
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.ParameterDefinitions == b.ParameterDefinitions &&
|
||||
a.ReturnDefinition == b.ReturnDefinition &&
|
||||
a.MinTimeBetweenRuns == b.MinTimeBetweenRuns;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="ConnectionConfig"/> instances for equality across
|
||||
/// the fields that travel in the deployment package: protocol, primary and
|
||||
/// backup configuration JSON, and failover retry count. Used by callers that
|
||||
/// need to detect connection-endpoint drift.
|
||||
/// </summary>
|
||||
/// <param name="a">First connection configuration.</param>
|
||||
/// <param name="b">Second connection configuration.</param>
|
||||
/// <returns>True when both configurations are equal.</returns>
|
||||
public static bool ConnectionsEqual(ConnectionConfig a, ConnectionConfig b)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(a);
|
||||
ArgumentNullException.ThrowIfNull(b);
|
||||
|
||||
return a.Protocol == b.Protocol &&
|
||||
a.ConfigurationJson == b.ConfigurationJson &&
|
||||
a.BackupConfigurationJson == b.BackupConfigurationJson &&
|
||||
a.FailoverRetryCount == b.FailoverRetryCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TemplateEngine-018: produces a per-connection diff between two flattened
|
||||
/// configurations, emitting Added / Removed / Changed entries keyed by the
|
||||
/// connection name. Mirrors the existing <see cref="ComputeEntityDiff{T}"/>
|
||||
/// shape used for attributes / alarms / scripts but is exposed as a separate
|
||||
/// method because <see cref="ConfigurationDiff"/> in
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Commons</c> does not yet carry a <c>ConnectionChanges</c>
|
||||
/// slot — the public diff record will be extended in a paired Commons change
|
||||
/// (this file is the only one in this fix's scope). A null
|
||||
/// <c>Connections</c> dictionary on either side is treated as the empty map.
|
||||
/// </summary>
|
||||
/// <param name="oldConfig">The previously deployed configuration, or null
|
||||
/// for first-time deployment.</param>
|
||||
/// <param name="newConfig">The current flattened configuration.</param>
|
||||
/// <returns>Added / Removed / Changed entries keyed by connection name,
|
||||
/// sorted ordinally by <see cref="DiffEntry{T}.CanonicalName"/>.</returns>
|
||||
public IReadOnlyList<DiffEntry<ConnectionConfig>> ComputeConnectionsDiff(
|
||||
FlattenedConfiguration? oldConfig,
|
||||
FlattenedConfiguration newConfig)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newConfig);
|
||||
|
||||
// Project both sides to (name, config) pairs. A null Connections
|
||||
// dictionary models the no-connections case (first deploy, or all
|
||||
// bindings cleared) — treat it as empty so the diff still reports the
|
||||
// counterpart side's entries as Added / Removed rather than throwing.
|
||||
var oldPairs = (oldConfig?.Connections ?? new Dictionary<string, ConnectionConfig>())
|
||||
.Select(kvp => (kvp.Key, kvp.Value))
|
||||
.ToList();
|
||||
var newPairs = (newConfig.Connections ?? new Dictionary<string, ConnectionConfig>())
|
||||
.Select(kvp => (kvp.Key, kvp.Value))
|
||||
.ToList();
|
||||
|
||||
return ComputeEntityDiff(
|
||||
oldPairs,
|
||||
newPairs,
|
||||
pair => pair.Key,
|
||||
(a, b) => ConnectionsEqual(a.Value, b.Value))
|
||||
.Select(entry => new DiffEntry<ConnectionConfig>
|
||||
{
|
||||
CanonicalName = entry.CanonicalName,
|
||||
ChangeType = entry.ChangeType,
|
||||
OldValue = entry.OldValue.Value,
|
||||
NewValue = entry.NewValue.Value,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the template flattening algorithm.
|
||||
/// Takes a template inheritance/composition graph plus instance overrides and connection bindings,
|
||||
/// and produces a fully resolved FlattenedConfiguration.
|
||||
///
|
||||
/// Resolution order (most specific wins):
|
||||
/// 1. Instance overrides (highest priority)
|
||||
/// 2. Child template (most derived first in inheritance chain)
|
||||
/// 3. Parent templates (walking up inheritance chain)
|
||||
/// 4. Composed modules (recursively flattened with path-qualified canonical names)
|
||||
///
|
||||
/// Locked fields cannot be overridden by instance overrides.
|
||||
/// </summary>
|
||||
public class FlatteningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces a fully flattened configuration for an instance.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance to flatten.</param>
|
||||
/// <param name="templateChain">
|
||||
/// The inheritance chain from most-derived to root (index 0 = the instance's template,
|
||||
/// last = the ultimate base template). Each template includes its own attributes, alarms, scripts.
|
||||
/// </param>
|
||||
/// <param name="compositionMap">
|
||||
/// Map of template ID → list of compositions (composed module definitions).
|
||||
/// For each composition, the key is the parent template ID and the value includes the
|
||||
/// composed template's resolved chain.
|
||||
/// </param>
|
||||
/// <param name="composedTemplateChains">
|
||||
/// Map of composed template ID → its inheritance chain (same format as templateChain).
|
||||
/// </param>
|
||||
/// <param name="dataConnections">
|
||||
/// Available data connections for resolving connection bindings.
|
||||
/// </param>
|
||||
/// <returns>A Result containing the FlattenedConfiguration or an error message.</returns>
|
||||
public Result<FlattenedConfiguration> Flatten(
|
||||
Instance instance,
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
if (templateChain.Count == 0)
|
||||
return Result<FlattenedConfiguration>.Failure("Template chain is empty.");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 0: Validate LockedInDerived isn't violated by any chain.
|
||||
var lockError = ValidateLockedInDerived(templateChain);
|
||||
if (lockError != null)
|
||||
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||
foreach (var composedChain in composedTemplateChains.Values)
|
||||
{
|
||||
lockError = ValidateLockedInDerived(composedChain);
|
||||
if (lockError != null)
|
||||
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||
}
|
||||
|
||||
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
||||
var attributes = ResolveInheritedAttributes(templateChain);
|
||||
|
||||
// Step 2: Resolve composed module attributes with path-qualified names
|
||||
ResolveComposedAttributes(templateChain, compositionMap, composedTemplateChains, attributes);
|
||||
|
||||
// Step 3: Apply instance overrides (respecting locks)
|
||||
ApplyInstanceOverrides(instance.AttributeOverrides, attributes);
|
||||
|
||||
// Step 4: Apply connection bindings
|
||||
ApplyConnectionBindings(instance.ConnectionBindings, attributes, dataConnections);
|
||||
|
||||
// Step 5: Resolve alarms from inheritance chain.
|
||||
// alarmScriptIds maps a resolved alarm's canonical name to the
|
||||
// TemplateScript.Id of its on-trigger script (if any).
|
||||
var alarmScriptIds = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var alarms = ResolveInheritedAlarms(templateChain, prefix: null, alarmScriptIds);
|
||||
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms, alarmScriptIds);
|
||||
ApplyInstanceAlarmOverrides(instance.AlarmOverrides, alarms);
|
||||
|
||||
// Step 6: Resolve scripts from inheritance chain.
|
||||
// scriptCanonicalById maps a TemplateScript.Id to its resolved
|
||||
// canonical name, used to wire up alarm on-trigger script refs.
|
||||
var scriptCanonicalById = new Dictionary<int, string>();
|
||||
var scripts = ResolveInheritedScripts(templateChain, prefix: null, scriptCanonicalById);
|
||||
ResolveComposedScripts(templateChain, compositionMap, composedTemplateChains, scripts, scriptCanonicalById);
|
||||
|
||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||
ResolveAlarmScriptReferences(alarms, alarmScriptIds, scriptCanonicalById);
|
||||
|
||||
// Step 8: Collect connection configurations for deployment packaging
|
||||
var connections = new Dictionary<string, ConnectionConfig>();
|
||||
foreach (var attr in attributes.Values)
|
||||
{
|
||||
if (attr.BoundDataConnectionId.HasValue &&
|
||||
!string.IsNullOrEmpty(attr.BoundDataConnectionName) &&
|
||||
!connections.ContainsKey(attr.BoundDataConnectionName))
|
||||
{
|
||||
if (dataConnections.TryGetValue(attr.BoundDataConnectionId.Value, out var conn))
|
||||
{
|
||||
connections[attr.BoundDataConnectionName] = new ConnectionConfig
|
||||
{
|
||||
Protocol = conn.Protocol,
|
||||
ConfigurationJson = conn.PrimaryConfiguration,
|
||||
BackupConfigurationJson = conn.BackupConfiguration,
|
||||
FailoverRetryCount = conn.FailoverRetryCount
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instance.UniqueName,
|
||||
TemplateId = instance.TemplateId,
|
||||
SiteId = instance.SiteId,
|
||||
AreaId = instance.AreaId,
|
||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Connections = connections.Count > 0 ? connections : null,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Result<FlattenedConfiguration>.Success(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<FlattenedConfiguration>.Failure($"Flattening failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedAttribute> ResolveInheritedAttributes(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
|
||||
// Walk from base (last) to most-derived (first) so derived values win.
|
||||
// IsInherited rows on a derived template are placeholders that should
|
||||
// not shadow the live base value; they only contribute a row when the
|
||||
// base lacks one.
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
if (result.TryGetValue(attr.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
if (attr.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
result[attr.Name] = new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = attr.Name,
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType.ToString(),
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
||||
/// attribute/alarm/script marked LockedInDerived that a downstream derived
|
||||
/// template overrides (IsInherited=false). Returns null on success or an
|
||||
/// error message describing the first offending entries.
|
||||
/// </summary>
|
||||
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var alarmLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
if (attr.LockedInDerived)
|
||||
attrLocks[attr.Name] = template;
|
||||
else if (!attr.IsInherited && attrLocks.TryGetValue(attr.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (alarm.LockedInDerived)
|
||||
alarmLocks[alarm.Name] = template;
|
||||
else if (!alarm.IsInherited && alarmLocks.TryGetValue(alarm.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Alarm '{alarm.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (script.LockedInDerived)
|
||||
scriptLocks[script.Name] = template;
|
||||
else if (!script.IsInherited && scriptLocks.TryGetValue(script.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Script '{script.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
private static void ResolveComposedAttributes(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
// Process compositions from each template in the chain
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
composition, composition.InstanceName,
|
||||
compositionMap, composedTemplateChains, attributes, new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively resolves the attributes of a composed module and every
|
||||
/// module nested inside it (to arbitrary depth), path-qualifying each
|
||||
/// canonical name with the accumulated <paramref name="prefix"/>.
|
||||
/// </summary>
|
||||
private static void ResolveComposedAttributesRecursive(
|
||||
TemplateComposition composition,
|
||||
string prefix,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
|
||||
var composedAttrs = ResolveInheritedAttributes(composedChain);
|
||||
foreach (var (name, attr) in composedAttrs)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
// Don't overwrite if already defined (most-derived wins)
|
||||
if (!attributes.ContainsKey(canonicalName))
|
||||
{
|
||||
attributes[canonicalName] = attr with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, attributes, visited);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInstanceOverrides(
|
||||
ICollection<InstanceAttributeOverride> overrides,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!attributes.TryGetValue(ovr.AttributeName, out var existing))
|
||||
continue; // Cannot add new attributes via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked attributes cannot be overridden
|
||||
|
||||
attributes[ovr.AttributeName] = existing with
|
||||
{
|
||||
Value = ovr.OverrideValue,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-instance alarm overrides on top of the
|
||||
/// inheritance-and-composition resolved alarms. Skips overrides for
|
||||
/// alarms that are locked at the template level. For HiLo triggers the
|
||||
/// override JSON is merged setpoint-by-setpoint (preserving inherited
|
||||
/// keys not present in the override); for other trigger types the
|
||||
/// override replaces the whole TriggerConfiguration.
|
||||
/// </summary>
|
||||
private static void ApplyInstanceAlarmOverrides(
|
||||
ICollection<InstanceAlarmOverride> overrides,
|
||||
Dictionary<string, ResolvedAlarm> alarms)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!alarms.TryGetValue(ovr.AlarmCanonicalName, out var existing))
|
||||
continue; // Cannot add new alarms via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked alarms cannot be overridden
|
||||
|
||||
var newConfig = existing.TriggerConfiguration;
|
||||
if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride))
|
||||
{
|
||||
newConfig = existing.TriggerType == nameof(AlarmTriggerType.HiLo)
|
||||
? MergeHiLoConfig(existing.TriggerConfiguration, ovr.TriggerConfigurationOverride)
|
||||
: ovr.TriggerConfigurationOverride;
|
||||
}
|
||||
|
||||
alarms[ovr.AlarmCanonicalName] = existing with
|
||||
{
|
||||
TriggerConfiguration = newConfig,
|
||||
PriorityLevel = ovr.PriorityLevelOverride ?? existing.PriorityLevel,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyConnectionBindings(
|
||||
ICollection<InstanceConnectionBinding> bindings,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (!attributes.TryGetValue(binding.AttributeName, out var existing))
|
||||
continue;
|
||||
|
||||
if (existing.DataSourceReference == null)
|
||||
continue; // Only data-sourced attributes can have connection bindings
|
||||
|
||||
if (!dataConnections.TryGetValue(binding.DataConnectionId, out var connection))
|
||||
continue;
|
||||
|
||||
attributes[binding.AttributeName] = existing with
|
||||
{
|
||||
BoundDataConnectionId = connection.Id,
|
||||
BoundDataConnectionName = connection.Name,
|
||||
BoundDataConnectionProtocol = connection.Protocol
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves alarms from an inheritance chain. When <paramref name="prefix"/>
|
||||
/// is non-null the alarm names are returned bare (caller path-qualifies);
|
||||
/// the keys of the returned dictionary are always bare alarm names.
|
||||
/// <paramref name="alarmScriptIds"/> is populated with the on-trigger
|
||||
/// script id of each resolved alarm keyed by the canonical name the alarm
|
||||
/// will ultimately carry (bare name when <paramref name="prefix"/> is null,
|
||||
/// otherwise <c>prefix.name</c>).
|
||||
/// </summary>
|
||||
private static Dictionary<string, ResolvedAlarm> ResolveInheritedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
string? prefix,
|
||||
Dictionary<string, int> alarmScriptIds)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAlarm>(StringComparer.Ordinal);
|
||||
var scriptIdByName = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (result.TryGetValue(alarm.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
// IsInherited rows on a derived template are placeholders
|
||||
// that must not shadow the live base alarm; they only
|
||||
// contribute a row when the base lacks one.
|
||||
if (alarm.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
// HiLo per-setpoint override: derived templates can supply a
|
||||
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
||||
// remaining setpoints inherited. Other trigger types replace
|
||||
// the whole config on override (current behavior).
|
||||
var triggerConfig = alarm.TriggerConfiguration;
|
||||
if (existing != null
|
||||
&& alarm.TriggerType == AlarmTriggerType.HiLo
|
||||
&& existing.TriggerType == nameof(AlarmTriggerType.HiLo))
|
||||
{
|
||||
triggerConfig = MergeHiLoConfig(existing.TriggerConfiguration, triggerConfig);
|
||||
}
|
||||
|
||||
result[alarm.Name] = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = alarm.Name,
|
||||
Description = alarm.Description,
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = triggerConfig,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
Source = source
|
||||
};
|
||||
|
||||
if (alarm.OnTriggerScriptId.HasValue)
|
||||
scriptIdByName[alarm.Name] = alarm.OnTriggerScriptId.Value;
|
||||
else
|
||||
scriptIdByName.Remove(alarm.Name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, scriptId) in scriptIdByName)
|
||||
{
|
||||
var canonical = prefix == null ? name : $"{prefix}.{name}";
|
||||
alarmScriptIds[canonical] = scriptId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a derived HiLo trigger configuration onto an inherited one.
|
||||
/// Top-level keys present in <paramref name="derivedJson"/> override the
|
||||
/// inherited values; keys absent in the derived config are inherited.
|
||||
/// Returns the derived config verbatim on parse failure of either input —
|
||||
/// the existing whole-replace behavior is the safe fallback.
|
||||
/// </summary>
|
||||
/// <param name="inheritedJson">The parent template's HiLo trigger JSON, or null.</param>
|
||||
/// <param name="derivedJson">The child template's HiLo trigger JSON override, or null.</param>
|
||||
public static string? MergeHiLoConfig(string? inheritedJson, string? derivedJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inheritedJson)) return derivedJson;
|
||||
if (string.IsNullOrWhiteSpace(derivedJson)) return inheritedJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var inheritedDoc = JsonDocument.Parse(inheritedJson);
|
||||
using var derivedDoc = JsonDocument.Parse(derivedJson);
|
||||
|
||||
if (inheritedDoc.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| derivedDoc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
var derivedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
derivedKeys.Add(prop.Name);
|
||||
|
||||
// Inherited keys not present in derived survive.
|
||||
foreach (var prop in inheritedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (derivedKeys.Contains(prop.Name)) continue;
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
// Derived keys win.
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimal HiLo override JSON given the inherited config and
|
||||
/// an edited config — returns only the top-level keys whose values differ
|
||||
/// from the inherited config. Returns <c>null</c> when no keys differ (the
|
||||
/// caller should treat that as "no override").
|
||||
///
|
||||
/// Value comparison is type-aware so that JSON-escape differences (e.g.,
|
||||
/// a literal em-dash in the inherited config vs. <c>—</c> in the
|
||||
/// editor's serialized output) don't produce false-positive diffs. On
|
||||
/// parse failure of either input, returns <paramref name="editedJson"/>
|
||||
/// verbatim — safe fallback that matches the existing whole-replace
|
||||
/// semantics.
|
||||
/// </summary>
|
||||
/// <param name="inheritedJson">The parent template's HiLo trigger JSON, or null.</param>
|
||||
/// <param name="editedJson">The user-edited HiLo trigger JSON, or null.</param>
|
||||
public static string? DiffHiLoConfig(string? inheritedJson, string? editedJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(editedJson)) return null;
|
||||
if (string.IsNullOrWhiteSpace(inheritedJson)) return editedJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var inheritedDoc = JsonDocument.Parse(inheritedJson);
|
||||
using var editedDoc = JsonDocument.Parse(editedJson);
|
||||
|
||||
if (inheritedDoc.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| editedDoc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return editedJson;
|
||||
}
|
||||
|
||||
var changed = new List<JsonProperty>();
|
||||
foreach (var prop in editedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!inheritedDoc.RootElement.TryGetProperty(prop.Name, out var inhProp))
|
||||
{
|
||||
changed.Add(prop);
|
||||
continue;
|
||||
}
|
||||
if (!ValuesEquivalent(prop.Value, inhProp))
|
||||
changed.Add(prop);
|
||||
}
|
||||
|
||||
if (changed.Count == 0) return null;
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var p in changed) p.WriteTo(writer);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return editedJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two JSON values by their decoded meaning rather than their
|
||||
/// raw text. Strings are unescaped before comparison so equivalent values
|
||||
/// in different escape forms (e.g., a literal "—" vs. "—") match.
|
||||
/// Numbers compare by their double value so trailing-zero differences
|
||||
/// don't produce false diffs.
|
||||
/// </summary>
|
||||
private static bool ValuesEquivalent(JsonElement a, JsonElement b)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind) return false;
|
||||
return a.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => a.GetString() == b.GetString(),
|
||||
JsonValueKind.Number => a.GetDouble() == b.GetDouble(),
|
||||
JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true,
|
||||
_ => a.GetRawText() == b.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static void ResolveComposedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, int> alarmScriptIds)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
composition, composition.InstanceName,
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds,
|
||||
new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively resolves the alarms of a composed module and every module
|
||||
/// nested inside it, path-qualifying each canonical name with the
|
||||
/// accumulated <paramref name="prefix"/>.
|
||||
/// </summary>
|
||||
private static void ResolveComposedAlarmsRecursive(
|
||||
TemplateComposition composition,
|
||||
string prefix,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, int> alarmScriptIds,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
|
||||
var composedAlarms = ResolveInheritedAlarms(composedChain, prefix, alarmScriptIds);
|
||||
foreach (var (name, alarm) in composedAlarms)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!alarms.ContainsKey(canonicalName))
|
||||
{
|
||||
alarms[canonicalName] = alarm with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
TriggerConfiguration = PrefixTriggerAttribute(alarm.TriggerConfiguration, prefix),
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves scripts from an inheritance chain. The returned dictionary is
|
||||
/// keyed by bare script name. <paramref name="scriptCanonicalById"/> is
|
||||
/// populated with each resolved script's <see cref="TemplateScript.Id"/>
|
||||
/// mapped to the canonical name it will ultimately carry (bare when
|
||||
/// <paramref name="prefix"/> is null, otherwise <c>prefix.name</c>).
|
||||
/// </summary>
|
||||
private static Dictionary<string, ResolvedScript> ResolveInheritedScripts(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
string? prefix,
|
||||
Dictionary<int, string> scriptCanonicalById)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedScript>(StringComparer.Ordinal);
|
||||
var idByName = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (result.TryGetValue(script.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
if (script.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
result[script.Name] = new ResolvedScript
|
||||
{
|
||||
CanonicalName = script.Name,
|
||||
Code = script.Code,
|
||||
IsLocked = script.IsLocked,
|
||||
TriggerType = script.TriggerType,
|
||||
TriggerConfiguration = script.TriggerConfiguration,
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
Source = source
|
||||
};
|
||||
idByName[script.Name] = script.Id;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, id) in idByName)
|
||||
{
|
||||
if (id == 0) continue; // unsaved row — no stable id to map
|
||||
var canonical = prefix == null ? name : $"{prefix}.{name}";
|
||||
scriptCanonicalById[id] = canonical;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedScripts(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts,
|
||||
Dictionary<int, string> scriptCanonicalById)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
composition, composition.InstanceName, parentPath: "",
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById,
|
||||
new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively resolves the scripts of a composed module and every module
|
||||
/// nested inside it, path-qualifying each canonical name with the
|
||||
/// accumulated <paramref name="prefix"/>. <paramref name="parentPath"/> is
|
||||
/// the path of the enclosing module — empty for a depth-1 composition
|
||||
/// (parent is the root template) and the enclosing module's
|
||||
/// <c>prefix</c> for deeper nesting — and is carried into each script's
|
||||
/// <see cref="ScriptScope"/> so a nested script's <c>Parent.X</c>
|
||||
/// resolves against its real parent module.
|
||||
/// </summary>
|
||||
private static void ResolveComposedScriptsRecursive(
|
||||
TemplateComposition composition,
|
||||
string prefix,
|
||||
string parentPath,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts,
|
||||
Dictionary<int, string> scriptCanonicalById,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
|
||||
var composedScripts = ResolveInheritedScripts(composedChain, prefix, scriptCanonicalById);
|
||||
foreach (var (name, script) in composedScripts)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!scripts.ContainsKey(canonicalName))
|
||||
{
|
||||
scripts[canonicalName] = script with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed",
|
||||
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: parentPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefixes the "attribute" (or "attributeName") field in alarm trigger configuration JSON
|
||||
/// with the composition instance name, so composed alarms monitor the path-qualified attribute.
|
||||
/// </summary>
|
||||
private static string? PrefixTriggerAttribute(string? triggerConfigJson, string prefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(triggerConfigJson)) return triggerConfigJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(triggerConfigJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Find the attribute key name used
|
||||
string? attrKey = null;
|
||||
if (root.TryGetProperty("attribute", out _)) attrKey = "attribute";
|
||||
else if (root.TryGetProperty("attributeName", out _)) attrKey = "attributeName";
|
||||
|
||||
if (attrKey == null) return triggerConfigJson;
|
||||
|
||||
var attrValue = root.GetProperty(attrKey).GetString();
|
||||
if (string.IsNullOrEmpty(attrValue)) return triggerConfigJson;
|
||||
|
||||
// Rebuild JSON with prefixed attribute name
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
using (var writer = new System.Text.Json.Utf8JsonWriter(ms))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == attrKey)
|
||||
writer.WriteString(attrKey, $"{prefix}.{attrValue}");
|
||||
else
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return triggerConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves alarm on-trigger script references from <see cref="TemplateScript.Id"/>
|
||||
/// values to the canonical (path-qualified) names of the corresponding
|
||||
/// resolved scripts. <paramref name="alarmScriptIds"/> maps an alarm's
|
||||
/// canonical name to the id of its on-trigger script; <paramref name="scriptCanonicalById"/>
|
||||
/// maps a script id to the canonical name it carries in the flattened
|
||||
/// configuration. An alarm whose on-trigger script id has no matching
|
||||
/// resolved script is left with a <c>null</c> reference — semantic
|
||||
/// validation then reports the dangling reference.
|
||||
/// </summary>
|
||||
private static void ResolveAlarmScriptReferences(
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, int> alarmScriptIds,
|
||||
Dictionary<int, string> scriptCanonicalById)
|
||||
{
|
||||
foreach (var (alarmCanonicalName, scriptId) in alarmScriptIds)
|
||||
{
|
||||
if (!alarms.TryGetValue(alarmCanonicalName, out var alarm))
|
||||
continue;
|
||||
|
||||
scriptCanonicalById.TryGetValue(scriptId, out var scriptCanonicalName);
|
||||
alarms[alarmCanonicalName] = alarm with
|
||||
{
|
||||
OnTriggerScriptCanonicalName = scriptCanonicalName
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||
/// 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()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the given flattened configuration.
|
||||
/// The hash is computed over a canonical JSON representation with sorted keys,
|
||||
/// excluding volatile fields like GeneratedAtUtc.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to hash.</param>
|
||||
public string ComputeHash(FlattenedConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Create a hashable representation that excludes volatile fields
|
||||
var hashInput = new HashableConfiguration
|
||||
{
|
||||
InstanceUniqueName = configuration.InstanceUniqueName,
|
||||
TemplateId = configuration.TemplateId,
|
||||
SiteId = configuration.SiteId,
|
||||
AreaId = configuration.AreaId,
|
||||
Attributes = configuration.Attributes
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAttribute
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType,
|
||||
Description = a.Description,
|
||||
IsLocked = a.IsLocked,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
BoundDataConnectionId = a.BoundDataConnectionId
|
||||
})
|
||||
.ToList(),
|
||||
Alarms = configuration.Alarms
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAlarm
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
Description = a.Description,
|
||||
PriorityLevel = a.PriorityLevel,
|
||||
IsLocked = a.IsLocked,
|
||||
TriggerType = a.TriggerType,
|
||||
TriggerConfiguration = a.TriggerConfiguration,
|
||||
OnTriggerScriptCanonicalName = a.OnTriggerScriptCanonicalName
|
||||
})
|
||||
.ToList(),
|
||||
Scripts = configuration.Scripts
|
||||
.OrderBy(s => s.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(s => new HashableScript
|
||||
{
|
||||
CanonicalName = s.CanonicalName,
|
||||
Code = s.Code,
|
||||
IsLocked = s.IsLocked,
|
||||
TriggerType = s.TriggerType,
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks
|
||||
})
|
||||
.ToList(),
|
||||
Connections = configuration.Connections is { Count: > 0 }
|
||||
? new SortedDictionary<string, HashableConnection>(
|
||||
configuration.Connections.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => new HashableConnection
|
||||
{
|
||||
BackupConfigurationJson = kvp.Value.BackupConfigurationJson,
|
||||
ConfigurationJson = kvp.Value.ConfigurationJson,
|
||||
FailoverRetryCount = kvp.Value.FailoverRetryCount,
|
||||
Protocol = kvp.Value.Protocol
|
||||
}),
|
||||
StringComparer.Ordinal)
|
||||
: null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Collection of alarms in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||
/// <summary>
|
||||
/// The area identifier.
|
||||
/// </summary>
|
||||
public int? AreaId { get; init; }
|
||||
/// <summary>
|
||||
/// Collection of attributes in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableAttribute> Attributes { get; init; } = [];
|
||||
/// <summary>
|
||||
/// Data connection configurations keyed by connection name. Sorted by key
|
||||
/// (ordinal) to keep serialization deterministic. Null when the deployment
|
||||
/// package carries no connections.
|
||||
/// </summary>
|
||||
public SortedDictionary<string, HashableConnection>? Connections { get; init; }
|
||||
/// <summary>
|
||||
/// The unique instance name.
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Collection of scripts in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableScript> Scripts { get; init; } = [];
|
||||
/// <summary>
|
||||
/// The site identifier.
|
||||
/// </summary>
|
||||
public int SiteId { get; init; }
|
||||
/// <summary>
|
||||
/// The template identifier.
|
||||
/// </summary>
|
||||
public int TemplateId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The data connection identifier the attribute is bound to.
|
||||
/// </summary>
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
/// <summary>
|
||||
/// The canonical name of the attribute.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The data source reference for the attribute.
|
||||
/// </summary>
|
||||
public string? DataSourceReference { get; init; }
|
||||
/// <summary>
|
||||
/// The data type of the attribute.
|
||||
/// </summary>
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The attribute description (authoring-time documentation that still
|
||||
/// travels with the deployed payload).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
/// <summary>
|
||||
/// Whether the attribute is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The attribute value.
|
||||
/// </summary>
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAlarm
|
||||
{
|
||||
/// <summary>
|
||||
/// The canonical name of the alarm.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The alarm description (authoring-time documentation that still
|
||||
/// travels with the deployed payload).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
/// <summary>
|
||||
/// Whether the alarm is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The canonical name of the script triggered by this alarm.
|
||||
/// </summary>
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
/// <summary>
|
||||
/// The priority level of the alarm.
|
||||
/// </summary>
|
||||
public int PriorityLevel { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger configuration for the alarm.
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger type of the alarm.
|
||||
/// </summary>
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Backup connection configuration JSON, if any.
|
||||
/// </summary>
|
||||
public string? BackupConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Primary connection configuration JSON.
|
||||
/// </summary>
|
||||
public string? ConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Number of failover retries before giving up.
|
||||
/// </summary>
|
||||
public int FailoverRetryCount { get; init; }
|
||||
/// <summary>
|
||||
/// Protocol name (e.g. "OpcUa").
|
||||
/// </summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableScript
|
||||
{
|
||||
/// <summary>
|
||||
/// The canonical name of the script.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The script code.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Whether the script is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The minimum time between runs in ticks.
|
||||
/// </summary>
|
||||
public long? MinTimeBetweenRunsTicks { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of parameter definitions.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of the return type definition.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger configuration for the script.
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger type of the script.
|
||||
/// </summary>
|
||||
public string? TriggerType { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces locking rules for template member overrides.
|
||||
///
|
||||
/// Locking rules:
|
||||
/// - Locked members cannot be overridden downstream (child templates or compositions).
|
||||
/// - Any level can lock an unlocked member (intermediate locking).
|
||||
/// - Once locked, a member stays locked — neither <see cref="TemplateAttribute.IsLocked"/>
|
||||
/// nor <see cref="TemplateAttribute.LockedInDerived"/> may be cleared after it has
|
||||
/// been set. The same one-way ratchet applies to alarms and scripts. This pins
|
||||
/// the design intent so a base template cannot retroactively re-allow derived
|
||||
/// overrides that were previously blocked (TemplateEngine-022).
|
||||
///
|
||||
/// Override granularity:
|
||||
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
|
||||
/// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed.
|
||||
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, params/return overridable; Name fixed.
|
||||
/// - Lock flag applies to the entire member (attribute/alarm/script).
|
||||
/// </summary>
|
||||
public static class LockEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that an attribute override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
/// <param name="original">The parent template's attribute definition.</param>
|
||||
/// <param name="proposed">The child template's proposed override.</param>
|
||||
public static string? ValidateAttributeOverride(
|
||||
TemplateAttribute original,
|
||||
TemplateAttribute proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Attribute '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// DataType is fixed — cannot change
|
||||
if (proposed.DataType != original.DataType)
|
||||
{
|
||||
return $"Attribute '{original.Name}': DataType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// DataSourceReference is fixed — cannot change
|
||||
if (proposed.DataSourceReference != original.DataSourceReference)
|
||||
{
|
||||
return $"Attribute '{original.Name}': DataSourceReference cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an alarm override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
/// <param name="original">The parent template's alarm definition.</param>
|
||||
/// <param name="proposed">The child template's proposed override.</param>
|
||||
public static string? ValidateAlarmOverride(
|
||||
TemplateAlarm original,
|
||||
TemplateAlarm proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Alarm '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// Name is fixed
|
||||
if (proposed.Name != original.Name)
|
||||
{
|
||||
return $"Alarm '{original.Name}': Name cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// TriggerType is fixed
|
||||
if (proposed.TriggerType != original.TriggerType)
|
||||
{
|
||||
return $"Alarm '{original.Name}': TriggerType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a script override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
/// <param name="original">The parent template's script definition.</param>
|
||||
/// <param name="proposed">The child template's proposed override.</param>
|
||||
public static string? ValidateScriptOverride(
|
||||
TemplateScript original,
|
||||
TemplateScript proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Script '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// Name is fixed
|
||||
if (proposed.Name != original.Name)
|
||||
{
|
||||
return $"Script '{original.Name}': Name cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a lock flag change is legal.
|
||||
/// Locking is allowed on unlocked members. Unlocking is never allowed.
|
||||
/// </summary>
|
||||
/// <param name="originalIsLocked">The current lock state of the member.</param>
|
||||
/// <param name="proposedIsLocked">The proposed lock state.</param>
|
||||
/// <param name="memberName">Name of the member being changed, for error messages.</param>
|
||||
public static string? ValidateLockChange(bool originalIsLocked, bool proposedIsLocked, string memberName)
|
||||
{
|
||||
if (originalIsLocked && !proposedIsLocked)
|
||||
{
|
||||
return $"Member '{memberName}' is locked and cannot be unlocked.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a <see cref="TemplateAttribute.LockedInDerived"/> (or alarm/script)
|
||||
/// flag change is legal. <c>LockedInDerived</c> follows the same one-way ratchet
|
||||
/// as <c>IsLocked</c> — once set on a base template, it cannot be cleared,
|
||||
/// otherwise derived templates that were previously blocked from overriding the
|
||||
/// field would become retroactively allowed (TemplateEngine-022).
|
||||
/// </summary>
|
||||
/// <param name="originalLockedInDerived">Current <c>LockedInDerived</c> state.</param>
|
||||
/// <param name="proposedLockedInDerived">Proposed <c>LockedInDerived</c> state.</param>
|
||||
/// <param name="memberName">Name of the member being changed, for error messages.</param>
|
||||
public static string? ValidateLockedInDerivedChange(
|
||||
bool originalLockedInDerived,
|
||||
bool proposedLockedInDerived,
|
||||
string memberName)
|
||||
{
|
||||
if (originalLockedInDerived && !proposedLockedInDerived)
|
||||
{
|
||||
return $"Member '{memberName}' is locked-in-derived and that lock cannot be cleared.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers all template engine services (template, flattening, validation, and domain services).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddTemplateEngine(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<TemplateService>();
|
||||
services.AddScoped<SharedScriptService>();
|
||||
|
||||
// Flattening services (stateless utilities)
|
||||
services.AddTransient<FlatteningService>();
|
||||
services.AddTransient<DiffService>();
|
||||
services.AddTransient<RevisionHashService>();
|
||||
|
||||
// Validation services (stateless utilities)
|
||||
services.AddTransient<ScriptCompiler>();
|
||||
services.AddTransient<SemanticValidator>();
|
||||
services.AddTransient<ValidationService>();
|
||||
|
||||
// Domain services (depend on scoped DbContext / repositories)
|
||||
services.AddScoped<InstanceService>();
|
||||
services.AddScoped<SiteService>();
|
||||
services.AddScoped<AreaService>();
|
||||
services.AddScoped<TemplateFolderService>();
|
||||
services.AddScoped<TemplateDeletionService>();
|
||||
|
||||
// Note: CycleDetector, CollisionDetector, LockEnforcer, and TemplateResolver
|
||||
// are static utility classes and do not require DI registration.
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Akka.NET actors for the template engine (placeholder for future actor registration).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddTemplateEngineActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical area management per site.
|
||||
/// - CRUD for areas with parent-child relationships
|
||||
/// - Deletion constrained if instances are assigned
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class AreaService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AreaService with the specified repository and audit service.
|
||||
/// </summary>
|
||||
/// <param name="repository">The template engine repository for data access.</param>
|
||||
/// <param name="auditService">The audit service for logging area changes.</param>
|
||||
public AreaService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new area within a site.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the new area.</param>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="parentAreaId">Optional parent area identifier for hierarchical organization.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> CreateAreaAsync(
|
||||
string name, int siteId, int? parentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
// Validate parent area if specified
|
||||
if (parentAreaId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetAreaByIdAsync(parentAreaId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} not found.");
|
||||
if (parent.SiteId != siteId)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.ParentAreaId == parentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level in site {siteId}.");
|
||||
|
||||
var area = new Area(name)
|
||||
{
|
||||
SiteId = siteId,
|
||||
ParentAreaId = parentAreaId
|
||||
};
|
||||
|
||||
await _repository.AddAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an area's name.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="name">The new name for the area.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> UpdateAreaAsync(
|
||||
int areaId, string name, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == area.ParentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level.");
|
||||
|
||||
area.Name = name;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-parents an area within its site. `newParentAreaId == null` moves the area to the site root.
|
||||
/// Rejects: self-parent, descendant-parent (cycle), cross-site parent, name collision at new level.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="newParentAreaId">The new parent area identifier, or null to move to site root.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> MoveAreaAsync(
|
||||
int areaId, int? newParentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
if (newParentAreaId == areaId)
|
||||
return Result<Area>.Failure("An area cannot be its own parent.");
|
||||
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var newParent = await _repository.GetAreaByIdAsync(newParentAreaId.Value, cancellationToken);
|
||||
if (newParent == null)
|
||||
return Result<Area>.Failure($"Target parent area with ID {newParentAreaId.Value} not found.");
|
||||
if (newParent.SiteId != area.SiteId)
|
||||
return Result<Area>.Failure("Areas can only be moved within the same site.");
|
||||
}
|
||||
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Cycle prevention: the new parent must not be a descendant of the area being moved.
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var descendants = GetDescendantAreaIds(areaId, siblings);
|
||||
if (descendants.Contains(newParentAreaId.Value))
|
||||
return Result<Area>.Failure(
|
||||
$"Cannot move area '{area.Name}' under one of its own descendants.");
|
||||
}
|
||||
|
||||
if (newParentAreaId == area.ParentAreaId)
|
||||
return Result<Area>.Success(area);
|
||||
|
||||
var collision = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == newParentAreaId &&
|
||||
string.Equals(a.Name, area.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (collision != null)
|
||||
return Result<Area>.Failure(
|
||||
$"An area named '{area.Name}' already exists at the target level.");
|
||||
|
||||
area.ParentAreaId = newParentAreaId;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Move", "Area", area.Id.ToString(), area.Name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<bool>> DeleteAreaAsync(
|
||||
int areaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<bool>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for instances assigned to this area
|
||||
var allInstances = await _repository.GetInstancesBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var allAreas = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Collect this area and all descendant area IDs
|
||||
var descendantIds = GetDescendantAreaIds(areaId, allAreas);
|
||||
descendantIds.Add(areaId);
|
||||
|
||||
var assignedInstances = allInstances
|
||||
.Where(i => i.AreaId.HasValue && descendantIds.Contains(i.AreaId.Value))
|
||||
.ToList();
|
||||
|
||||
if (assignedInstances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", assignedInstances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': {assignedInstances.Count} instance(s) are assigned to it or its sub-areas ({names}{(assignedInstances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check for child areas (must delete children first, or we delete recursively)
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == areaId).ToList();
|
||||
if (childAreas.Count > 0)
|
||||
{
|
||||
var childNames = string.Join(", ", childAreas.Select(a => a.Name));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': it has child areas ({childNames}). Delete child areas first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteAreaAsync(areaId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Area", areaId.ToString(), area.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all areas for a site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single area by ID.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Area?> GetAreaByIdAsync(int areaId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
|
||||
private static HashSet<int> GetDescendantAreaIds(int parentId, IReadOnlyList<Area> allAreas)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(parentId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var child in allAreas.Where(a => a.ParentAreaId == current))
|
||||
{
|
||||
if (result.Add(child.Id))
|
||||
queue.Enqueue(child.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Instance CRUD operations.
|
||||
/// - Create instance from template at site
|
||||
/// - Assign to area
|
||||
/// - Override non-locked attribute values
|
||||
/// - Cannot add or remove attributes (only override existing ones)
|
||||
/// - Per-attribute connection binding (bulk assignment support)
|
||||
/// - Enabled/disabled state. Concurrent edits are last-write-wins — there is no
|
||||
/// version token or conflict detection on instance state, matching the design
|
||||
/// decision (Component-TemplateEngine.md: "Concurrent editing uses
|
||||
/// last-write-wins — no pessimistic locking or conflict detection"). Optimistic
|
||||
/// concurrency in the system applies to deployment status records, not here.
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InstanceService class.
|
||||
/// </summary>
|
||||
/// <param name="repository">Template engine repository for data access.</param>
|
||||
/// <param name="auditService">Service for audit logging.</param>
|
||||
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance from a template at a site.
|
||||
/// </summary>
|
||||
/// <param name="uniqueName">Unique name for the instance.</param>
|
||||
/// <param name="templateId">ID of the template to instantiate.</param>
|
||||
/// <param name="siteId">ID of the site where the instance will reside.</param>
|
||||
/// <param name="areaId">Optional ID of the area to assign to the instance.</param>
|
||||
/// <param name="user">Username of the user creating the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the created instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> CreateInstanceAsync(
|
||||
string uniqueName,
|
||||
int templateId,
|
||||
int siteId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uniqueName))
|
||||
return Result<Instance>.Failure("Instance unique name is required.");
|
||||
|
||||
// Verify template exists
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for duplicate unique name
|
||||
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
|
||||
|
||||
// Verify area exists if specified
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != siteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
var instance = new Instance(uniqueName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SiteId = siteId,
|
||||
AreaId = areaId,
|
||||
State = InstanceState.Disabled // New instances start disabled
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
|
||||
uniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns an instance to an area.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="areaId">ID of the area to assign to, or null to clear assignment.</param>
|
||||
/// <param name="user">Username of the user making the assignment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the updated instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> AssignToAreaAsync(
|
||||
int instanceId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != instance.SiteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
|
||||
}
|
||||
|
||||
instance.AreaId = areaId;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
|
||||
/// Cannot add or remove attributes — only override values of existing template attributes.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="attributeName">Name of the attribute to override.</param>
|
||||
/// <param name="overrideValue">Override value, or null to clear.</param>
|
||||
/// <param name="user">Username of the user setting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the attribute override or failure message.</returns>
|
||||
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
|
||||
int instanceId,
|
||||
string attributeName,
|
||||
string? overrideValue,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify attribute exists in the template and is not locked
|
||||
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
||||
if (templateAttr == null)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
|
||||
|
||||
if (templateAttr.IsLocked)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' is locked and cannot be overridden.");
|
||||
|
||||
// Find existing override or create new one
|
||||
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.OverrideValue = overrideValue;
|
||||
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
|
||||
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAttributeOverride(attributeName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
OverrideValue = overrideValue
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
|
||||
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a per-instance alarm override. The alarm must exist in the
|
||||
/// instance's effective alarm set (direct, inherited, or composed) and
|
||||
/// must not be locked. For HiLo alarms, the override JSON merges into the
|
||||
/// inherited TriggerConfiguration setpoint-by-setpoint; for binary trigger
|
||||
/// types, it replaces the whole config.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="alarmCanonicalName">Canonical name of the alarm to override.</param>
|
||||
/// <param name="triggerConfigurationOverride">Override JSON for the trigger configuration.</param>
|
||||
/// <param name="priorityLevelOverride">Override priority level, or null to use template value.</param>
|
||||
/// <param name="user">Username of the user setting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the alarm override or failure message.</returns>
|
||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string? triggerConfigurationOverride,
|
||||
int? priorityLevelOverride,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify the alarm exists in the instance's effective alarm set and is
|
||||
// not locked. The effective set is resolved via TemplateResolver so that
|
||||
// composed (path-qualified) and inherited alarms are found — a lookup
|
||||
// against the template's direct alarms alone would miss them, silently
|
||||
// accepting an override for a non-existent name or bypassing the lock
|
||||
// rule for a composed alarm. Mirrors SetAttributeOverrideAsync.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var resolvedAlarm = TemplateResolver
|
||||
.ResolveAllMembers(instance.TemplateId, allTemplates)
|
||||
.FirstOrDefault(m => m.MemberType == "Alarm" && m.CanonicalName == alarmCanonicalName);
|
||||
|
||||
if (resolvedAlarm == null)
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' does not exist in template {instance.TemplateId}. " +
|
||||
"Cannot override an unknown alarm.");
|
||||
|
||||
if (resolvedAlarm.IsLocked)
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
||||
|
||||
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
||||
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
||||
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
|
||||
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TriggerConfigurationOverride = triggerConfigurationOverride,
|
||||
PriorityLevelOverride = priorityLevelOverride
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
|
||||
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a per-instance alarm override. After removal the instance
|
||||
/// inherits the template alarm config unchanged.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="alarmCanonicalName">Canonical name of the alarm to delete the override for.</param>
|
||||
/// <param name="user">Username of the user deleting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure.</returns>
|
||||
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
|
||||
|
||||
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
|
||||
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="bindings">Connection bindings to set.</param>
|
||||
/// <param name="user">Username of the user setting the bindings.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the updated bindings or failure message.</returns>
|
||||
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
||||
int instanceId,
|
||||
IReadOnlyList<ConnectionBinding> bindings,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
|
||||
|
||||
var results = new List<InstanceConnectionBinding>();
|
||||
|
||||
foreach (var (attrName, connId) in bindings)
|
||||
{
|
||||
if (existingMap.TryGetValue(attrName, out var existing))
|
||||
{
|
||||
existing.DataConnectionId = connId;
|
||||
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
||||
results.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var binding = new InstanceConnectionBinding(attrName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DataConnectionId = connId
|
||||
};
|
||||
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
||||
results.Add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
|
||||
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
|
||||
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user enabling the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the enabled instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Enabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user disabling the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the disabled instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Disabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user deleting the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure.</returns>
|
||||
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an instance by ID.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The instance, or null if not found.</returns>
|
||||
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all instances for a site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">ID of the site.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of instances for the site.</returns>
|
||||
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Site and data connection management.
|
||||
/// - Site CRUD (name, identifier, description)
|
||||
/// - Data connection CRUD (name, protocol, config) — each connection belongs to exactly one site
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class SiteService
|
||||
{
|
||||
private readonly ISiteRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>Initializes the service with its repository and audit collaborators.</summary>
|
||||
/// <param name="repository">Repository for site and data connection persistence.</param>
|
||||
/// <param name="auditService">Audit service for logging CRUD operations.</param>
|
||||
public SiteService(ISiteRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
// --- Site CRUD ---
|
||||
|
||||
/// <summary>Creates a new site with the given name and identifier, rejecting duplicates.</summary>
|
||||
/// <param name="name">Display name of the site.</param>
|
||||
/// <param name="siteIdentifier">Unique machine-readable site identifier.</param>
|
||||
/// <param name="description">Optional description.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the new <see cref="Site"/>, or a failure result with a message.</returns>
|
||||
public async Task<Result<Site>> CreateSiteAsync(
|
||||
string name, string siteIdentifier, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Site>.Failure("Site name is required.");
|
||||
if (string.IsNullOrWhiteSpace(siteIdentifier))
|
||||
return Result<Site>.Failure("Site identifier is required.");
|
||||
|
||||
var existing = await _repository.GetSiteByIdentifierAsync(siteIdentifier, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Site>.Failure($"Site with identifier '{siteIdentifier}' already exists.");
|
||||
|
||||
var site = new Site(name, siteIdentifier) { Description = description };
|
||||
await _repository.AddSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
/// <summary>Updates the name and description of an existing site.</summary>
|
||||
/// <param name="siteId">Primary key of the site to update.</param>
|
||||
/// <param name="name">New display name.</param>
|
||||
/// <param name="description">New optional description.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the updated <see cref="Site"/>, or a failure result if not found.</returns>
|
||||
public async Task<Result<Site>> UpdateSiteAsync(
|
||||
int siteId, string name, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<Site>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
site.Name = name;
|
||||
site.Description = description;
|
||||
await _repository.UpdateSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a site, rejecting the request if any instances are assigned to it.</summary>
|
||||
/// <param name="siteId">Primary key of the site to delete.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result with <c>true</c>, or a failure result with a message if blocked.</returns>
|
||||
public async Task<Result<bool>> DeleteSiteAsync(int siteId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<bool>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
// Check for instances assigned to this site
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete site '{site.Name}': {instances.Count} instance(s) are assigned to it ({names}{(instances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
await _repository.DeleteSiteAsync(siteId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Site", siteId.ToString(), site.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>Returns the site with the given primary key, or <c>null</c> if not found.</summary>
|
||||
/// <param name="siteId">Primary key of the site.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Site?> GetSiteByIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>Returns all sites.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAllSitesAsync(cancellationToken);
|
||||
|
||||
// --- Data Connection CRUD ---
|
||||
|
||||
/// <summary>Creates a new data connection owned by the specified site.</summary>
|
||||
/// <param name="siteId">Primary key of the owning site.</param>
|
||||
/// <param name="name">Display name of the data connection.</param>
|
||||
/// <param name="protocol">Protocol identifier (e.g., "OpcUa").</param>
|
||||
/// <param name="primaryConfiguration">JSON configuration for the primary endpoint; may be null.</param>
|
||||
/// <param name="backupConfiguration">JSON configuration for the backup endpoint; may be null.</param>
|
||||
/// <param name="failoverRetryCount">Number of retries before switching to the backup endpoint.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the new <see cref="DataConnection"/>, or a failure result.</returns>
|
||||
public async Task<Result<DataConnection>> CreateDataConnectionAsync(
|
||||
int siteId, string name, string protocol, string? primaryConfiguration,
|
||||
string? backupConfiguration, int failoverRetryCount, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<DataConnection>.Failure("Data connection name is required.");
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
return Result<DataConnection>.Failure("Protocol is required.");
|
||||
|
||||
var connection = new DataConnection(name, protocol, siteId)
|
||||
{
|
||||
PrimaryConfiguration = primaryConfiguration,
|
||||
BackupConfiguration = backupConfiguration,
|
||||
FailoverRetryCount = failoverRetryCount
|
||||
};
|
||||
await _repository.AddDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing data connection's configuration.</summary>
|
||||
/// <param name="connectionId">Primary key of the data connection to update.</param>
|
||||
/// <param name="name">New display name.</param>
|
||||
/// <param name="protocol">New protocol identifier.</param>
|
||||
/// <param name="primaryConfiguration">New primary endpoint JSON configuration; may be null.</param>
|
||||
/// <param name="backupConfiguration">New backup endpoint JSON configuration; may be null.</param>
|
||||
/// <param name="failoverRetryCount">New failover retry count.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the updated <see cref="DataConnection"/>, or a failure result if not found.</returns>
|
||||
public async Task<Result<DataConnection>> UpdateDataConnectionAsync(
|
||||
int connectionId, string name, string protocol, string? primaryConfiguration,
|
||||
string? backupConfiguration, int failoverRetryCount, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<DataConnection>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
connection.Name = name;
|
||||
connection.Protocol = protocol;
|
||||
connection.PrimaryConfiguration = primaryConfiguration;
|
||||
connection.BackupConfiguration = backupConfiguration;
|
||||
connection.FailoverRetryCount = failoverRetryCount;
|
||||
await _repository.UpdateDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a data connection by its primary key.</summary>
|
||||
/// <param name="connectionId">Primary key of the data connection to delete.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result with <c>true</c>, or a failure result if not found.</returns>
|
||||
public async Task<Result<bool>> DeleteDataConnectionAsync(
|
||||
int connectionId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<bool>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(connectionId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "DataConnection",
|
||||
connectionId.ToString(), connection.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces template deletion constraints (WP-25).
|
||||
/// Template deletion is blocked when:
|
||||
/// - Instances reference the template
|
||||
/// - Child templates reference it (as parent)
|
||||
/// - Other templates compose it
|
||||
/// Returns clear error messages listing the referencing entities.
|
||||
/// </summary>
|
||||
public class TemplateDeletionService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="TemplateDeletionService"/> with the given repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to check deletion constraints and perform the delete.</param>
|
||||
public TemplateDeletionService(ITemplateEngineRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a template can be safely deleted and returns any blocking reasons.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The id of the template to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<bool>> CanDeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
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.");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check 1: Instances reference this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 2: Child templates reference it as parent. Split derived vs. regular.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||
|
||||
if (regularChildren.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (derivatives.Count > 0)
|
||||
{
|
||||
var compIds = 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 => compIds.Contains(x.Composition.Id))
|
||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||
var details = string.Join(", ", derivatives.Take(10).Select(d =>
|
||||
d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||
? label
|
||||
: $"'{d.Name}'"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first.");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
||||
// Read the Compositions navigation already loaded by GetAllTemplatesAsync
|
||||
// rather than issuing one GetCompositionsByTemplateIdAsync round-trip per
|
||||
// template (TemplateEngine-009) — this is the same source TemplateService
|
||||
// .DeleteTemplateAsync uses for the equivalent check.
|
||||
var composingTemplates = allTemplates
|
||||
.SelectMany(t => t.Compositions
|
||||
.Where(comp => comp.ComposedTemplateId == templateId)
|
||||
.Select(comp => (TemplateName: t.Name, comp.InstanceName)))
|
||||
.ToList();
|
||||
|
||||
if (composingTemplates.Count > 0)
|
||||
{
|
||||
var details = string.Join(", ",
|
||||
composingTemplates.Take(10).Select(c => $"'{c.TemplateName}' (as '{c.InstanceName}')"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {composingTemplates.Count} template(s) compose it ({details}{(composingTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
return Result<bool>.Failure(string.Join(" ", errors));
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template after checking all constraints.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The id of the template to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<bool>> DeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var canDelete = await CanDeleteTemplateAsync(templateId, cancellationToken);
|
||||
if (canDelete.IsFailure)
|
||||
return canDelete;
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
public class TemplateFolderService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with its required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="repository">Template engine repository for folder persistence.</param>
|
||||
/// <param name="auditService">Audit service for logging folder operations.</param>
|
||||
public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new template folder, enforcing name uniqueness within the parent.
|
||||
/// </summary>
|
||||
/// <param name="name">Display name for the new folder.</param>
|
||||
/// <param name="parentFolderId">Parent folder id, or null to create at root level.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> CreateFolderAsync(
|
||||
string name, int? parentFolderId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<TemplateFolder>.Failure("Folder name is required.");
|
||||
|
||||
if (parentFolderId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<TemplateFolder>.Failure($"Parent folder with ID {parentFolderId.Value} not found.");
|
||||
}
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.ParentFolderId == parentFolderId
|
||||
&& string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{name}' already exists at this level.");
|
||||
|
||||
var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId };
|
||||
await _repository.AddFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames an existing template folder, enforcing name uniqueness within its parent.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to rename.</param>
|
||||
/// <param name="newName">New display name for the folder.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> RenameFolderAsync(
|
||||
int folderId, string newName, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return Result<TemplateFolder>.Failure("Folder name is required.");
|
||||
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == folder.ParentFolderId
|
||||
&& string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{newName}' already exists at this level.");
|
||||
|
||||
folder.Name = newName;
|
||||
await _repository.UpdateFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves an existing template folder to a new parent, checking for cycles and name collisions.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to move.</param>
|
||||
/// <param name="newParentId">Target parent folder id, or null to move to root level.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> MoveFolderAsync(
|
||||
int folderId, int? newParentId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
if (newParentId.HasValue)
|
||||
{
|
||||
if (newParentId.Value == folderId)
|
||||
return Result<TemplateFolder>.Failure("Cannot move a folder into itself (cycle).");
|
||||
|
||||
var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken);
|
||||
if (newParent == null)
|
||||
return Result<TemplateFolder>.Failure($"Target folder with ID {newParentId.Value} not found.");
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
var byId = all.ToDictionary(f => f.Id);
|
||||
var cursor = newParentId;
|
||||
// Walk up from newParentId — if we encounter folderId, the move would create a cycle.
|
||||
// Bound iterations by byId.Count to defensively terminate on a malformed graph.
|
||||
var iterations = 0;
|
||||
while (cursor.HasValue)
|
||||
{
|
||||
if (cursor.Value == folderId)
|
||||
return Result<TemplateFolder>.Failure("Cannot move a folder under one of its descendants (cycle).");
|
||||
if (++iterations > byId.Count)
|
||||
return Result<TemplateFolder>.Failure("Folder hierarchy contains a cycle; cannot determine ancestry.");
|
||||
cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null;
|
||||
}
|
||||
|
||||
// Sibling-name uniqueness in destination.
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == newParentId
|
||||
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists in the target folder.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == null
|
||||
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists at the root.");
|
||||
}
|
||||
|
||||
folder.ParentFolderId = newParentId;
|
||||
await _repository.UpdateFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an empty template folder. Fails if the folder contains sub-folders or templates.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to delete.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<bool>> DeleteFolderAsync(
|
||||
int folderId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<bool>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
var allFolders = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
|
||||
var childFolderCount = allFolders.Count(f => f.ParentFolderId == folderId);
|
||||
var childTemplateCount = allTemplates.Count(t => t.FolderId == folderId);
|
||||
|
||||
if (childFolderCount > 0 || childTemplateCount > 0)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (childTemplateCount > 0)
|
||||
parts.Add($"{childTemplateCount} template{(childTemplateCount == 1 ? "" : "s")}");
|
||||
if (childFolderCount > 0)
|
||||
parts.Add($"{childFolderCount} subfolder{(childFolderCount == 1 ? "" : "s")}");
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete folder '{folder.Name}': it contains {string.Join(" and ", parts)}. " +
|
||||
"Move or delete contents first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteFolderAsync(folderId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateFolder", folderId.ToString(), folder.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// WP-5: Shared Script CRUD.
|
||||
/// System-wide scripts not associated with templates.
|
||||
/// Same parameter/return definition structure as template scripts.
|
||||
/// Includes syntax/structural validation (basic C# compilation check).
|
||||
/// </summary>
|
||||
public class SharedScriptService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SharedScriptService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="repository">The template engine repository for data access.</param>
|
||||
/// <param name="auditService">The audit service for logging operations.</param>
|
||||
public SharedScriptService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new shared script.
|
||||
/// </summary>
|
||||
/// <param name="name">The shared script name.</param>
|
||||
/// <param name="code">The shared script code.</param>
|
||||
/// <param name="parameterDefinitions">Optional parameter definitions JSON.</param>
|
||||
/// <param name="returnDefinition">Optional return definition JSON.</param>
|
||||
/// <param name="user">The user creating the script.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A result containing the created script or an error message.</returns>
|
||||
public async Task<Result<SharedScript>> CreateSharedScriptAsync(
|
||||
string name,
|
||||
string code,
|
||||
string? parameterDefinitions,
|
||||
string? returnDefinition,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<SharedScript>.Failure("Shared script name is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<SharedScript>.Failure("Shared script code is required.");
|
||||
|
||||
// Check unique name
|
||||
var existing = await _repository.GetSharedScriptByNameAsync(name, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<SharedScript>.Failure($"A shared script named '{name}' already exists.");
|
||||
|
||||
// Syntax/structural validation
|
||||
var syntaxError = ValidateSyntax(code);
|
||||
if (syntaxError != null)
|
||||
return Result<SharedScript>.Failure(syntaxError);
|
||||
|
||||
var script = new SharedScript(name, code)
|
||||
{
|
||||
ParameterDefinitions = parameterDefinitions,
|
||||
ReturnDefinition = returnDefinition
|
||||
};
|
||||
|
||||
// TemplateEngine-020: save the entity first so EF Core populates the
|
||||
// auto-generated key, then write the audit row with the real
|
||||
// script.Id, then save the audit row. The pre-fix order logged
|
||||
// EntityId = "0" because the audit row was queued before
|
||||
// SaveChangesAsync ran.
|
||||
await _repository.AddSharedScriptAsync(script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "SharedScript", script.Id.ToString(), name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<SharedScript>.Success(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing shared script.
|
||||
/// </summary>
|
||||
/// <param name="scriptId">The shared script ID to update.</param>
|
||||
/// <param name="code">The updated shared script code.</param>
|
||||
/// <param name="parameterDefinitions">Optional updated parameter definitions JSON.</param>
|
||||
/// <param name="returnDefinition">Optional updated return definition JSON.</param>
|
||||
/// <param name="user">The user updating the script.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A result containing the updated script or an error message.</returns>
|
||||
public async Task<Result<SharedScript>> UpdateSharedScriptAsync(
|
||||
int scriptId,
|
||||
string code,
|
||||
string? parameterDefinitions,
|
||||
string? returnDefinition,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (script == null)
|
||||
return Result<SharedScript>.Failure($"Shared script with ID {scriptId} not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<SharedScript>.Failure("Shared script code is required.");
|
||||
|
||||
// Syntax/structural validation
|
||||
var syntaxError = ValidateSyntax(code);
|
||||
if (syntaxError != null)
|
||||
return Result<SharedScript>.Failure(syntaxError);
|
||||
|
||||
script.Code = code;
|
||||
script.ParameterDefinitions = parameterDefinitions;
|
||||
script.ReturnDefinition = returnDefinition;
|
||||
|
||||
await _repository.UpdateSharedScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "SharedScript", scriptId.ToString(), script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<SharedScript>.Success(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a shared script.
|
||||
/// </summary>
|
||||
/// <param name="scriptId">The shared script ID to delete.</param>
|
||||
/// <param name="user">The user deleting the script.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A result containing true if successful or an error message.</returns>
|
||||
public async Task<Result<bool>> DeleteSharedScriptAsync(
|
||||
int scriptId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (script == null)
|
||||
return Result<bool>.Failure($"Shared script with ID {scriptId} not found.");
|
||||
|
||||
await _repository.DeleteSharedScriptAsync(scriptId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "SharedScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a shared script by ID.
|
||||
/// </summary>
|
||||
/// <param name="scriptId">The shared script ID.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The shared script if found; null otherwise.</returns>
|
||||
public async Task<SharedScript?> GetSharedScriptByIdAsync(int scriptId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all shared scripts.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A read-only list of all shared scripts.</returns>
|
||||
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetAllSharedScriptsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic structural validation of C# script code.
|
||||
/// Checks for balanced braces/brackets/parentheses. The scan is string- and
|
||||
/// comment-aware (see <see cref="Validation.CSharpDelimiterScanner"/>) so a
|
||||
/// delimiter inside a regular/verbatim/interpolated/raw string literal, a
|
||||
/// char literal, or a comment does not produce a false syntax error.
|
||||
/// Full Roslyn compilation would be added in a later phase when the scripting sandbox is available.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# code to validate.</param>
|
||||
/// <returns>An error message if validation fails; null if valid.</returns>
|
||||
internal static string? ValidateSyntax(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return "Script code cannot be empty.";
|
||||
|
||||
return Validation.CSharpDelimiterScanner.Scan(code) switch
|
||||
{
|
||||
Validation.CSharpDelimiterScanner.Mismatch.None => null,
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseBrace =>
|
||||
"Syntax error: unmatched closing brace '}'.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBrace =>
|
||||
"Syntax error: unmatched opening brace '{'.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseBracket =>
|
||||
"Syntax error: unmatched closing bracket ']'.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBracket =>
|
||||
"Syntax error: unmatched opening bracket '['.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseParen =>
|
||||
"Syntax error: unmatched closing parenthesis ')'.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnclosedParen =>
|
||||
"Syntax error: unmatched opening parenthesis '('.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBlockComment =>
|
||||
"Syntax error: unclosed block comment.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnterminatedString =>
|
||||
"Syntax error: unterminated string literal.",
|
||||
Validation.CSharpDelimiterScanner.Mismatch.UnterminatedChar =>
|
||||
"Syntax error: unterminated character literal.",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the hierarchical ("qualified") name of a composition-derived
|
||||
/// template. A derived template stores only its <em>contained</em> name — the
|
||||
/// owning composition slot's <c>InstanceName</c>, unique only within that owner.
|
||||
/// The qualified path (<c>Owner.Slot.Slot…</c>) is computed on demand by
|
||||
/// walking the <see cref="Template.OwnerCompositionId"/> chain up to the base
|
||||
/// template.
|
||||
/// </summary>
|
||||
public static class TemplateNaming
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the dotted hierarchical name of <paramref name="template"/>. For
|
||||
/// a base (non-derived) template this is just its stored name. The walk is
|
||||
/// null-safe: if any owner link is missing from the supplied lookups it
|
||||
/// stops and falls back to the stored contained name, and a cycle (which
|
||||
/// the composition graph should never contain) is broken defensively.
|
||||
/// </summary>
|
||||
/// <param name="template">The template whose qualified name to compute.</param>
|
||||
/// <param name="byId">Lookup of all templates by primary key.</param>
|
||||
/// <param name="compById">Lookup of all template compositions by primary key.</param>
|
||||
/// <returns>The dotted hierarchical name (e.g., <c>Owner.Slot.Name</c>).</returns>
|
||||
public static string QualifiedName(
|
||||
Template template,
|
||||
IReadOnlyDictionary<int, Template> byId,
|
||||
IReadOnlyDictionary<int, TemplateComposition> compById)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(byId);
|
||||
ArgumentNullException.ThrowIfNull(compById);
|
||||
|
||||
return Resolve(template, byId, compById, new HashSet<int>());
|
||||
}
|
||||
|
||||
private static string Resolve(
|
||||
Template template,
|
||||
IReadOnlyDictionary<int, Template> byId,
|
||||
IReadOnlyDictionary<int, TemplateComposition> compById,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
// Base template, broken owner link, or a cycle → the stored name is the
|
||||
// best (and contained) answer.
|
||||
if (!template.IsDerived
|
||||
|| template.OwnerCompositionId is not { } compId
|
||||
|| !compById.TryGetValue(compId, out var composition)
|
||||
|| !byId.TryGetValue(composition.TemplateId, out var owner)
|
||||
|| !visited.Add(template.Id))
|
||||
{
|
||||
return template.Name;
|
||||
}
|
||||
|
||||
return $"{Resolve(owner, byId, compById, visited)}.{composition.InstanceName}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Walks inheritance and composition chains to resolve effective template members.
|
||||
/// Produces canonical (path-qualified) names for composed module members.
|
||||
/// </summary>
|
||||
public static class TemplateResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a resolved member from any point in the inheritance/composition hierarchy.
|
||||
/// </summary>
|
||||
public sealed record ResolvedTemplateMember
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the canonical (path-qualified) name of the member.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the member type: "Attribute", "Alarm", or "Script".
|
||||
/// </summary>
|
||||
public string MemberType { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the ID of the template that originally defined this member.
|
||||
/// </summary>
|
||||
public int SourceTemplateId { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the ID of the member within its source template.
|
||||
/// </summary>
|
||||
public int MemberId { get; init; }
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this member is locked from override.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the module path for composed members, or null for direct members.
|
||||
/// </summary>
|
||||
public string? ModulePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ResolvedTemplateMember"/> record.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The canonical name of the member.</param>
|
||||
/// <param name="memberType">The member type: "Attribute", "Alarm", or "Script".</param>
|
||||
/// <param name="sourceTemplateId">The ID of the source template.</param>
|
||||
/// <param name="memberId">The member ID within the source template.</param>
|
||||
/// <param name="isLocked">Whether the member is locked from override.</param>
|
||||
/// <param name="modulePath">The module path for composed members; null for direct members.</param>
|
||||
public ResolvedTemplateMember(string canonicalName, string memberType, int sourceTemplateId, int memberId, bool isLocked, string? modulePath = null)
|
||||
{
|
||||
CanonicalName = canonicalName;
|
||||
MemberType = memberType;
|
||||
SourceTemplateId = sourceTemplateId;
|
||||
MemberId = memberId;
|
||||
IsLocked = isLocked;
|
||||
ModulePath = modulePath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all effective members for a template, walking inheritance and composition chains.
|
||||
/// Child members override parent members of the same canonical name (unless locked).
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template ID to resolve members for.</param>
|
||||
/// <param name="allTemplates">The complete list of all templates for lookups.</param>
|
||||
/// <returns>A read-only list of resolved members for the template.</returns>
|
||||
public static IReadOnlyList<ResolvedTemplateMember> ResolveAllMembers(
|
||||
int templateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
// 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>();
|
||||
|
||||
// Build inheritance chain from root to leaf (root first, child last)
|
||||
var chain = BuildInheritanceChain(templateId, lookup);
|
||||
|
||||
// Start with root members, apply overrides from each child
|
||||
var effectiveMembers = new Dictionary<string, ResolvedTemplateMember>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tmpl in chain)
|
||||
{
|
||||
// Direct members
|
||||
AddDirectMembers(tmpl, prefix: null, effectiveMembers);
|
||||
|
||||
// Composed module members
|
||||
foreach (var composition in tmpl.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
AddComposedMembers(composedTemplate, composition.InstanceName, lookup, effectiveMembers, new HashSet<int>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveMembers.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inheritance chain from root ancestor to the specified template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TemplateEngine-019: the parent walk uses the <see cref="int?"/>
|
||||
/// <see cref="Template.ParentTemplateId"/> directly — only a missing
|
||||
/// (<c>null</c>) value means "no parent". The legacy <c>0</c>-as-no-parent
|
||||
/// sentinel that was removed from <c>CycleDetector</c> in the
|
||||
/// TemplateEngine-013 fix had silently truncated chains whenever a real
|
||||
/// template id of 0 appeared (e.g. import-staging / not-yet-saved rows);
|
||||
/// the duplicate-tolerant <c>BuildLookup</c> upstream means an Id of 0 is
|
||||
/// a valid node here and must walk the chain like any other.
|
||||
/// </remarks>
|
||||
/// <param name="templateId">The template ID to build the chain for.</param>
|
||||
/// <param name="lookup">A dictionary mapping template IDs to templates.</param>
|
||||
/// <returns>A read-only list of templates from root to the specified template.</returns>
|
||||
public static IReadOnlyList<Template> BuildInheritanceChain(
|
||||
int templateId,
|
||||
IReadOnlyDictionary<int, Template> lookup)
|
||||
{
|
||||
var chain = new List<Template>();
|
||||
int? currentId = templateId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
|
||||
{
|
||||
if (!visited.Add(currentId.Value))
|
||||
break; // Safety: cycle detected
|
||||
|
||||
chain.Add(current);
|
||||
currentId = current.ParentTemplateId;
|
||||
}
|
||||
|
||||
chain.Reverse(); // Root first
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a member by canonical name in the resolved member set.
|
||||
/// Used to check override/lock constraints.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The canonical name of the member to find.</param>
|
||||
/// <param name="parentTemplateId">The template ID to resolve members from.</param>
|
||||
/// <param name="allTemplates">The complete list of all templates for lookups.</param>
|
||||
/// <returns>The resolved member if found; null otherwise.</returns>
|
||||
public static ResolvedTemplateMember? FindMemberByCanonicalName(
|
||||
string canonicalName,
|
||||
int parentTemplateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var members = ResolveAllMembers(parentTemplateId, allTemplates);
|
||||
return members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
||||
}
|
||||
|
||||
private static void AddDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
Dictionary<string, ResolvedTemplateMember> effectiveMembers)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Attribute", template.Id, attr.Id, attr.IsLocked, prefix);
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Alarm", template.Id, alarm.Id, alarm.IsLocked, prefix);
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Script", template.Id, script.Id, script.IsLocked, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddComposedMembers(
|
||||
Template template,
|
||||
string prefix,
|
||||
Dictionary<int, Template> lookup,
|
||||
Dictionary<string, ResolvedTemplateMember> effectiveMembers,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
AddDirectMembers(template, prefix, effectiveMembers);
|
||||
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
AddComposedMembers(nested, nestedPrefix, lookup, effectiveMembers, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// String/comment-aware scanner for the balanced-delimiter ("does it look like
|
||||
/// valid C#") checks used by <see cref="ScriptCompiler"/> and
|
||||
/// <c>SharedScriptService.ValidateSyntax</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// This is <b>not</b> a compiler. It is an interim structural check that walks
|
||||
/// the source once and tracks <c>{}</c>, <c>[]</c> and <c>()</c> depth while
|
||||
/// correctly skipping over the C# lexical constructs in which a delimiter is
|
||||
/// inert: line/block comments, regular string literals (with <c>\</c> escapes),
|
||||
/// verbatim strings (<c>@"..."</c>, where <c>""</c> escapes a quote and <c>\</c>
|
||||
/// is literal), interpolated strings (<c>$"..."</c> / <c>$@"..."</c> — the holes
|
||||
/// <c>{...}</c> are code and <c>{{</c>/<c>}}</c> are escaped braces), raw string
|
||||
/// literals (<c>"""..."""</c>), and char literals (<c>'}'</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// It is intentionally conservative: when the real Roslyn-based compiler is
|
||||
/// wired in (see <see cref="ScriptCompiler"/>) this hand-rolled scan should be
|
||||
/// replaced by <c>CSharpSyntaxTree.ParseText</c> diagnostics. Until then this
|
||||
/// scanner removes the false positives that a naive character count produced
|
||||
/// for valid scripts containing a delimiter inside a string or comment.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class CSharpDelimiterScanner
|
||||
{
|
||||
/// <summary>The kind of delimiter mismatch found, if any.</summary>
|
||||
internal enum Mismatch
|
||||
{
|
||||
None,
|
||||
UnexpectedCloseBrace,
|
||||
UnexpectedCloseBracket,
|
||||
UnexpectedCloseParen,
|
||||
UnclosedBrace,
|
||||
UnclosedBracket,
|
||||
UnclosedParen,
|
||||
UnclosedBlockComment,
|
||||
UnterminatedString,
|
||||
UnterminatedChar,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="pattern"/> occurs in a <b>code</b>
|
||||
/// region of <paramref name="code"/> — i.e. not wholly inside a string
|
||||
/// literal, char literal, or comment. Used by the interim forbidden-API
|
||||
/// scan so that the inert text <c>System.IO.</c> in a comment or string
|
||||
/// literal is not flagged as a forbidden API call (TemplateEngine-006).
|
||||
///
|
||||
/// <para>
|
||||
/// This removes the false-positive half of the substring scan. It does
|
||||
/// <b>not</b> close the bypass half: namespace aliases, <c>using static</c>,
|
||||
/// and <c>global::</c>-qualified references still evade a pure text match.
|
||||
/// Authoritative forbidden-API enforcement requires Roslyn semantic symbol
|
||||
/// analysis and is deferred to the real script compiler / Site Runtime
|
||||
/// sandbox; this check is advisory only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="code">The C# source code to scan.</param>
|
||||
/// <param name="pattern">The substring to search for in code regions only.</param>
|
||||
internal static bool ContainsInCode(string code, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
return false;
|
||||
|
||||
// Blank out every string/char-literal/comment span, then do an ordinary
|
||||
// substring search over what remains (the code regions).
|
||||
var codeOnly = BlankNonCodeSpans(code);
|
||||
return codeOnly.Contains(pattern, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the content of every comment, string literal, and char literal
|
||||
/// with spaces (newlines preserved), leaving only code regions intact.
|
||||
/// Delimiter characters themselves are also blanked so a pattern cannot
|
||||
/// straddle a literal boundary.
|
||||
/// </summary>
|
||||
private static string BlankNonCodeSpans(string code)
|
||||
{
|
||||
var buffer = code.ToCharArray();
|
||||
int n = code.Length;
|
||||
int i = 0;
|
||||
|
||||
void Blank(int from, int to)
|
||||
{
|
||||
for (int k = from; k < to && k < n; k++)
|
||||
if (buffer[k] != '\n' && buffer[k] != '\r')
|
||||
buffer[k] = ' ';
|
||||
}
|
||||
|
||||
while (i < n)
|
||||
{
|
||||
char c = code[i];
|
||||
char next = i + 1 < n ? code[i + 1] : '\0';
|
||||
int start = i;
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
i += 2;
|
||||
while (i < n && code[i] != '\n') i++;
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
i += 2;
|
||||
while (i < n && !(code[i] == '*' && i + 1 < n && code[i + 1] == '/')) i++;
|
||||
if (i < n) i += 2;
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
|
||||
{
|
||||
SkipRawString(code, ref i);
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
if (c == '$')
|
||||
{
|
||||
int j = i + 1;
|
||||
bool verbatim = false;
|
||||
if (j < n && code[j] == '@') { verbatim = true; j++; }
|
||||
if (j < n && code[j] == '"')
|
||||
{
|
||||
i = j;
|
||||
SkipInterpolatedString(code, ref i, verbatim);
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (c == '@' && next == '"')
|
||||
{
|
||||
i++;
|
||||
SkipVerbatimString(code, ref i);
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
SkipRegularString(code, ref i);
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
if (c == '\'')
|
||||
{
|
||||
SkipCharLiteral(code, ref i);
|
||||
Blank(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="code"/> once and reports the first structural
|
||||
/// delimiter problem, or <see cref="Mismatch.None"/> when the source is
|
||||
/// balanced. Delimiters inside comments, strings, and char literals are
|
||||
/// ignored.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# source code to scan for delimiter balance.</param>
|
||||
internal static Mismatch Scan(string code)
|
||||
{
|
||||
int brace = 0, bracket = 0, paren = 0;
|
||||
int i = 0;
|
||||
int n = code.Length;
|
||||
|
||||
while (i < n)
|
||||
{
|
||||
char c = code[i];
|
||||
char next = i + 1 < n ? code[i + 1] : '\0';
|
||||
|
||||
// Line comment.
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
i += 2;
|
||||
while (i < n && code[i] != '\n') i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block comment.
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
i += 2;
|
||||
bool closed = false;
|
||||
while (i < n)
|
||||
{
|
||||
if (code[i] == '*' && i + 1 < n && code[i + 1] == '/')
|
||||
{
|
||||
i += 2;
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!closed) return Mismatch.UnclosedBlockComment;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Raw string literal: three or more consecutive quotes open it; the
|
||||
// same number of quotes closes it. Detected before $/@-prefixed and
|
||||
// plain strings.
|
||||
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
|
||||
{
|
||||
if (!SkipRawString(code, ref i)) return Mismatch.UnterminatedString;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Interpolated string ($"..." or $@"..." / @$"...").
|
||||
if (c == '$')
|
||||
{
|
||||
int j = i + 1;
|
||||
bool verbatim = false;
|
||||
if (j < n && code[j] == '@') { verbatim = true; j++; }
|
||||
if (j < n && code[j] == '"')
|
||||
{
|
||||
i = j;
|
||||
if (!SkipInterpolatedString(code, ref i, verbatim)) return Mismatch.UnterminatedString;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Verbatim string (@"...").
|
||||
if (c == '@' && next == '"')
|
||||
{
|
||||
i++; // now on the opening quote
|
||||
if (!SkipVerbatimString(code, ref i)) return Mismatch.UnterminatedString;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular string literal.
|
||||
if (c == '"')
|
||||
{
|
||||
if (!SkipRegularString(code, ref i)) return Mismatch.UnterminatedString;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Char literal.
|
||||
if (c == '\'')
|
||||
{
|
||||
if (!SkipCharLiteral(code, ref i)) return Mismatch.UnterminatedChar;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '{': brace++; break;
|
||||
case '}':
|
||||
brace--;
|
||||
if (brace < 0) return Mismatch.UnexpectedCloseBrace;
|
||||
break;
|
||||
case '[': bracket++; break;
|
||||
case ']':
|
||||
bracket--;
|
||||
if (bracket < 0) return Mismatch.UnexpectedCloseBracket;
|
||||
break;
|
||||
case '(': paren++; break;
|
||||
case ')':
|
||||
paren--;
|
||||
if (paren < 0) return Mismatch.UnexpectedCloseParen;
|
||||
break;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (brace != 0) return Mismatch.UnclosedBrace;
|
||||
if (bracket != 0) return Mismatch.UnclosedBracket;
|
||||
if (paren != 0) return Mismatch.UnclosedParen;
|
||||
return Mismatch.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances <paramref name="i"/> past a regular <c>"..."</c> string literal.
|
||||
/// On entry <c>code[i] == '"'</c>. Returns false if the string is unterminated.
|
||||
/// </summary>
|
||||
private static bool SkipRegularString(string code, ref int i)
|
||||
{
|
||||
int n = code.Length;
|
||||
i++; // past opening quote
|
||||
while (i < n)
|
||||
{
|
||||
char c = code[i];
|
||||
if (c == '\\') { i += 2; continue; } // escape — skip next char
|
||||
if (c == '\n') return false; // unterminated (no multi-line)
|
||||
if (c == '"') { i++; return true; }
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances past a verbatim <c>@"..."</c> string. On entry <c>code[i] == '"'</c>.
|
||||
/// Inside, <c>\</c> is literal and <c>""</c> is an escaped quote.
|
||||
/// </summary>
|
||||
private static bool SkipVerbatimString(string code, ref int i)
|
||||
{
|
||||
int n = code.Length;
|
||||
i++; // past opening quote
|
||||
while (i < n)
|
||||
{
|
||||
if (code[i] == '"')
|
||||
{
|
||||
if (i + 1 < n && code[i + 1] == '"') { i += 2; continue; } // escaped quote
|
||||
i++;
|
||||
return true;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances past an interpolated string. <paramref name="verbatim"/> selects
|
||||
/// the <c>$@"..."</c> escaping rules. Interpolation holes <c>{...}</c> are
|
||||
/// skipped over (their braces are code, not literal text); <c>{{</c>/<c>}}</c>
|
||||
/// are escaped braces. On entry <c>code[i] == '"'</c>.
|
||||
/// </summary>
|
||||
private static bool SkipInterpolatedString(string code, ref int i, bool verbatim)
|
||||
{
|
||||
int n = code.Length;
|
||||
i++; // past opening quote
|
||||
while (i < n)
|
||||
{
|
||||
char c = code[i];
|
||||
|
||||
if (!verbatim && c == '\\') { i += 2; continue; }
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
if (verbatim && i + 1 < n && code[i + 1] == '"') { i += 2; continue; }
|
||||
i++;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c == '{')
|
||||
{
|
||||
if (i + 1 < n && code[i + 1] == '{') { i += 2; continue; } // escaped brace
|
||||
// Interpolation hole — skip to the matching '}', tracking nested
|
||||
// braces so a hole containing an object initializer is handled.
|
||||
i++;
|
||||
int depth = 1;
|
||||
while (i < n && depth > 0)
|
||||
{
|
||||
char h = code[i];
|
||||
if (h == '{') depth++;
|
||||
else if (h == '}') depth--;
|
||||
else if (h == '"')
|
||||
{
|
||||
// A nested string inside the hole.
|
||||
if (!SkipRegularString(code, ref i)) return false;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '}' && i + 1 < n && code[i + 1] == '}') { i += 2; continue; } // escaped brace
|
||||
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances past a raw string literal <c>"""..."""</c> (C# 11). On entry
|
||||
/// <c>code[i]</c> is the first of three or more opening quotes.
|
||||
/// </summary>
|
||||
private static bool SkipRawString(string code, ref int i)
|
||||
{
|
||||
int n = code.Length;
|
||||
int openCount = 0;
|
||||
while (i < n && code[i] == '"') { openCount++; i++; }
|
||||
|
||||
// Look for a run of the same number of quotes.
|
||||
while (i < n)
|
||||
{
|
||||
if (code[i] == '"')
|
||||
{
|
||||
int closeCount = 0;
|
||||
int start = i;
|
||||
while (i < n && code[i] == '"') { closeCount++; i++; }
|
||||
if (closeCount >= openCount) return true;
|
||||
// Fewer quotes than the opener — they are literal content; keep scanning.
|
||||
if (closeCount == 0) i = start + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances past a <c>'x'</c> char literal. On entry <c>code[i] == '\''</c>.
|
||||
/// </summary>
|
||||
private static bool SkipCharLiteral(string code, ref int i)
|
||||
{
|
||||
int n = code.Length;
|
||||
i++; // past opening quote
|
||||
while (i < n)
|
||||
{
|
||||
char c = code[i];
|
||||
if (c == '\\') { i += 2; continue; }
|
||||
if (c == '\n') return false;
|
||||
if (c == '\'') { i++; return true; }
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates script code by attempting to compile it using Roslyn.
|
||||
/// In production, this would compile C# scripts against a stub ScriptApi assembly
|
||||
/// that provides the allowed API surface (attribute read/write, CallScript, CallShared, etc.)
|
||||
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
|
||||
///
|
||||
/// For now, this implementation performs basic syntax validation.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>SECURITY LIMITATION (TemplateEngine-006):</b> the forbidden-API check below
|
||||
/// is an interim, <i>advisory</i> text scan — it is NOT an authoritative trust-model
|
||||
/// boundary. <see cref="CSharpDelimiterScanner.ContainsInCode"/> removes the
|
||||
/// false-positive half (forbidden text inside a string/comment is ignored), but a
|
||||
/// determined script can still bypass the literal patterns via namespace aliases,
|
||||
/// <c>using static</c>, or <c>global::</c>-qualified references. Authoritative
|
||||
/// enforcement requires Roslyn semantic symbol analysis of the referenced
|
||||
/// types/namespaces and is the responsibility of the real script compiler and the
|
||||
/// Site Runtime sandbox. Do not rely on this class as the sole trust-model gate.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ScriptCompiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Forbidden namespace patterns — scripts (and trigger expressions, via
|
||||
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
|
||||
/// under the same trust model as scripts, so the list is shared from here rather
|
||||
/// than duplicated.
|
||||
///
|
||||
/// <para>
|
||||
/// Matched with <see cref="CSharpDelimiterScanner.ContainsInCode"/> against code
|
||||
/// regions only. This is advisory — see the class summary's SECURITY LIMITATION
|
||||
/// note; the substring patterns are bypassable and the authoritative check is
|
||||
/// deferred to Roslyn semantic analysis.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static readonly string[] ForbiddenPatterns =
|
||||
[
|
||||
"System.IO.",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading.",
|
||||
"System.Reflection.",
|
||||
"System.Net.Sockets.",
|
||||
"System.Net.Http.",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to compile a script and returns success or a compilation error.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# script code.</param>
|
||||
/// <param name="scriptName">The canonical name of the script (for error messages).</param>
|
||||
/// <returns>Success if the script compiles, or Failure with the error message.</returns>
|
||||
public Result<bool> TryCompile(string code, string scriptName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
|
||||
|
||||
// Check for forbidden APIs. Advisory only (see class summary): the scan is
|
||||
// code-region-aware so forbidden text inside a string/comment is ignored,
|
||||
// but it remains a substring match and is not an authoritative boundary.
|
||||
foreach (var pattern in ForbiddenPatterns)
|
||||
{
|
||||
if (CSharpDelimiterScanner.ContainsInCode(code, pattern))
|
||||
{
|
||||
return Result<bool>.Failure(
|
||||
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
|
||||
"Scripts cannot use System.IO, Process, Threading, Reflection, or raw network APIs.");
|
||||
}
|
||||
}
|
||||
|
||||
// Basic structural validation: balanced braces/brackets/parens. The scan
|
||||
// is string- and comment-aware (see CSharpDelimiterScanner) so a delimiter
|
||||
// inside a regular/verbatim/interpolated/raw string, a char literal, or a
|
||||
// comment does not produce a false mismatch. This remains an interim check
|
||||
// until the Roslyn-based compiler is wired in.
|
||||
var mismatch = CSharpDelimiterScanner.Scan(code);
|
||||
return mismatch switch
|
||||
{
|
||||
CSharpDelimiterScanner.Mismatch.None =>
|
||||
Result<bool>.Success(true),
|
||||
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBrace =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnclosedBrace =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unclosed opening brace)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBracket =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unexpected closing bracket)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnclosedBracket =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unclosed opening bracket)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnexpectedCloseParen =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unexpected closing parenthesis)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnclosedParen =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unclosed opening parenthesis)."),
|
||||
CSharpDelimiterScanner.Mismatch.UnclosedBlockComment =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment."),
|
||||
CSharpDelimiterScanner.Mismatch.UnterminatedString =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has an unterminated string literal."),
|
||||
CSharpDelimiterScanner.Mismatch.UnterminatedChar =>
|
||||
Result<bool>.Failure($"Script '{scriptName}' has an unterminated character literal."),
|
||||
_ => Result<bool>.Success(true),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation rules for a FlattenedConfiguration:
|
||||
/// - CallScript/CallShared targets must reference existing scripts
|
||||
/// - Parameter count and types must match
|
||||
/// - Return type compatibility
|
||||
/// - Trigger operand types: RangeViolation requires numeric attribute
|
||||
/// - On-trigger script must exist
|
||||
/// - Instance scripts cannot call alarm on-trigger scripts
|
||||
/// </summary>
|
||||
public class SemanticValidator
|
||||
{
|
||||
// Known numeric data types for RangeViolation trigger type validation
|
||||
private static readonly HashSet<string> NumericDataTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Int32", "Float", "Double"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs all semantic validation rules.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
/// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
|
||||
public ValidationResult Validate(
|
||||
FlattenedConfiguration configuration,
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
var scriptNames = new HashSet<string>(
|
||||
configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
var sharedScriptNames = new HashSet<string>(
|
||||
(sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
var attributeMap = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
foreach (var a in configuration.Attributes)
|
||||
{
|
||||
// Skip duplicates — naming collisions are reported separately
|
||||
attributeMap.TryAdd(a.CanonicalName, a);
|
||||
}
|
||||
|
||||
// Collect alarm on-trigger script names for cross-call violation checks
|
||||
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName))
|
||||
alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName);
|
||||
}
|
||||
|
||||
// Build parameter maps for call target validation
|
||||
var scriptParamMap = BuildParameterMap(configuration.Scripts);
|
||||
var sharedParamMap = BuildParameterMap(sharedScripts ?? []);
|
||||
var scriptReturnMap = BuildReturnMap(configuration.Scripts);
|
||||
var sharedReturnMap = BuildReturnMap(sharedScripts ?? []);
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
var callTargets = ExtractCallTargets(script.Code);
|
||||
|
||||
foreach (var call in callTargets)
|
||||
{
|
||||
if (call.IsShared)
|
||||
{
|
||||
// CallShared targets must reference existing shared scripts
|
||||
if (!sharedScriptNames.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
|
||||
$"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// CallScript targets must reference existing instance scripts
|
||||
if (!scriptNames.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
|
||||
$"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
|
||||
|
||||
// Instance scripts cannot call alarm on-trigger scripts
|
||||
if (alarmOnTriggerScripts.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation,
|
||||
$"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Call-type scripts have parameter definitions
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
|
||||
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate alarm trigger operand types
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
// RangeViolation requires numeric attribute
|
||||
if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
{
|
||||
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
|
||||
{
|
||||
if (!NumericDataTypes.Contains(attr.DataType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HiLo requires numeric attribute + ordered setpoints
|
||||
if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
{
|
||||
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
|
||||
{
|
||||
if (!NumericDataTypes.Contains(attr.DataType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
|
||||
|
||||
// At least one setpoint must be configured — otherwise the alarm
|
||||
// can never fire.
|
||||
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
|
||||
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
|
||||
// must sit strictly below the lowest Hi-side band — otherwise the
|
||||
// bands overlap and the evaluator's behavior is ambiguous.
|
||||
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
|
||||
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
|
||||
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
|
||||
&& lowSide >= highSide)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Deadbands must be non-negative — negative deadband would invert
|
||||
// the hysteresis (alarm could escape faster than it entered).
|
||||
foreach (var (name, value) in new (string, double?)[] {
|
||||
("LoLo deadband", setpoints.LoLoDeadband),
|
||||
("Lo deadband", setpoints.LoDeadband),
|
||||
("Hi deadband", setpoints.HiDeadband),
|
||||
("HiHi deadband", setpoints.HiHiDeadband)
|
||||
})
|
||||
{
|
||||
if (value is { } d && d < 0)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On-trigger script must exist
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
|
||||
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound,
|
||||
$"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static void ValidateCallParameters(
|
||||
string callerName,
|
||||
CallTarget call,
|
||||
Dictionary<string, List<string>> paramMap,
|
||||
List<ValidationEntry> errors)
|
||||
{
|
||||
if (!paramMap.TryGetValue(call.TargetName, out var expectedParams))
|
||||
return;
|
||||
|
||||
if (call.ArgumentCount != expectedParams.Count)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
|
||||
$"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
|
||||
callerName));
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
|
||||
{
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parameters = ParseParameterDefinitions(script.ParameterDefinitions);
|
||||
result[script.CanonicalName] = parameters;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
result[script.CanonicalName] = script.ReturnDefinition;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a parameter definitions JSON string (JSON Schema or legacy flat array) and returns the declared parameter names.
|
||||
/// </summary>
|
||||
/// <param name="parameterDefinitionsJson">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
|
||||
internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
|
||||
// JSON Schema: { type:"object", properties:{ name:{...}, ... }, required:[...] }
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("properties", out var props)
|
||||
&& props.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return props.EnumerateObject().Select(p => p.Name).ToList();
|
||||
}
|
||||
}
|
||||
// Legacy flat form: [{ name, type, required? }]
|
||||
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call targets from script code by simple pattern matching.
|
||||
/// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
|
||||
/// </summary>
|
||||
/// <param name="code">The script source code to scan.</param>
|
||||
internal static List<CallTarget> ExtractCallTargets(string code)
|
||||
{
|
||||
var results = new List<CallTarget>();
|
||||
|
||||
ExtractCallsOfType(code, "CallScript", false, results);
|
||||
ExtractCallsOfType(code, "CallShared", true, results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void ExtractCallsOfType(string code, string methodName, bool isShared, List<CallTarget> results)
|
||||
{
|
||||
var searchPattern = methodName + "(";
|
||||
int pos = 0;
|
||||
|
||||
while (pos < code.Length)
|
||||
{
|
||||
var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal);
|
||||
if (idx < 0) break;
|
||||
|
||||
var argsStart = idx + searchPattern.Length;
|
||||
var target = ExtractStringArgument(code, argsStart);
|
||||
if (target != null)
|
||||
{
|
||||
var argCount = CountArguments(code, argsStart);
|
||||
results.Add(new CallTarget
|
||||
{
|
||||
TargetName = target,
|
||||
IsShared = isShared,
|
||||
ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters
|
||||
});
|
||||
}
|
||||
|
||||
pos = argsStart;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractStringArgument(string code, int startPos)
|
||||
{
|
||||
// Skip whitespace
|
||||
var pos = startPos;
|
||||
while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++;
|
||||
|
||||
if (pos >= code.Length) return null;
|
||||
|
||||
// Expect a quote
|
||||
var quote = code[pos];
|
||||
if (quote != '"' && quote != '\'') return null;
|
||||
|
||||
pos++;
|
||||
var nameStart = pos;
|
||||
while (pos < code.Length && code[pos] != quote) pos++;
|
||||
|
||||
if (pos >= code.Length) return null;
|
||||
|
||||
return code[nameStart..pos];
|
||||
}
|
||||
|
||||
private static int CountArguments(string code, int startPos)
|
||||
{
|
||||
var depth = 1;
|
||||
var count = 1; // At least one argument (the name)
|
||||
var pos = startPos;
|
||||
|
||||
while (pos < code.Length && depth > 0)
|
||||
{
|
||||
switch (code[pos])
|
||||
{
|
||||
case '(':
|
||||
depth++;
|
||||
break;
|
||||
case ')':
|
||||
depth--;
|
||||
break;
|
||||
case ',' when depth == 1:
|
||||
count++;
|
||||
break;
|
||||
case '"':
|
||||
case '\'':
|
||||
// Skip string literals
|
||||
var quote = code[pos];
|
||||
pos++;
|
||||
while (pos < code.Length && code[pos] != quote)
|
||||
{
|
||||
if (code[pos] == '\\') pos++; // Skip escaped chars
|
||||
pos++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal record CallTarget
|
||||
{
|
||||
/// <summary>Name of the script being called.</summary>
|
||||
public string TargetName { get; init; } = string.Empty;
|
||||
/// <summary>True when the call is to a shared script via <c>CallShared</c>.</summary>
|
||||
public bool IsShared { get; init; }
|
||||
/// <summary>Number of non-name arguments passed to the call.</summary>
|
||||
public int ArgumentCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
|
||||
/// before deployment. Also available on-demand (same logic, no deployment trigger).
|
||||
///
|
||||
/// Validation checks:
|
||||
/// 1. Flattening success (no empty configuration)
|
||||
/// 2. No naming collisions
|
||||
/// 3. Script compilation (via ScriptCompiler)
|
||||
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
|
||||
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
|
||||
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
|
||||
/// 7. Connection binding completeness (all data-sourced attributes must have a binding)
|
||||
/// 8. Does NOT verify tag path resolution on devices
|
||||
/// </summary>
|
||||
public class ValidationService
|
||||
{
|
||||
private readonly SemanticValidator _semanticValidator;
|
||||
private readonly ScriptCompiler _scriptCompiler;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ValidationService with the specified dependencies.
|
||||
/// </summary>
|
||||
/// <param name="semanticValidator">The semantic validator for configuration validation.</param>
|
||||
/// <param name="scriptCompiler">The script compiler for validating script code.</param>
|
||||
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
|
||||
{
|
||||
_semanticValidator = semanticValidator;
|
||||
_scriptCompiler = scriptCompiler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience constructor that creates default dependencies.
|
||||
/// </summary>
|
||||
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the full validation pipeline on a flattened configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
/// <param name="sharedScripts">Optional list of shared scripts for validation context.</param>
|
||||
public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var results = new List<ValidationResult>
|
||||
{
|
||||
ValidateFlatteningSuccess(configuration),
|
||||
ValidateNamingCollisions(configuration),
|
||||
ValidateScriptCompilation(configuration),
|
||||
ValidateAlarmTriggerReferences(configuration),
|
||||
ValidateScriptTriggerReferences(configuration),
|
||||
ValidateExpressionTriggers(configuration),
|
||||
ValidateConnectionBindingCompleteness(configuration),
|
||||
_semanticValidator.Validate(configuration, sharedScripts)
|
||||
};
|
||||
|
||||
return ValidationResult.Merge(results.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that flattening produced a non-empty configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
|
||||
"Instance unique name is missing."));
|
||||
|
||||
if (configuration.Attributes.Count == 0 &&
|
||||
configuration.Alarms.Count == 0 &&
|
||||
configuration.Scripts.Count == 0)
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
|
||||
"Flattened configuration contains no attributes, alarms, or scripts.")]
|
||||
};
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that there are no naming collisions across entity types.
|
||||
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
|
||||
CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
|
||||
CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all scripts compile successfully using the ScriptCompiler.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
|
||||
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that alarm trigger configurations reference existing attributes.
|
||||
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var attributeNames = new HashSet<string>(
|
||||
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
continue;
|
||||
|
||||
var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && !attributeNames.Contains(attrName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
|
||||
$"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that script trigger configurations reference existing attributes.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var attributeNames = new HashSet<string>(
|
||||
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
|
||||
continue;
|
||||
|
||||
var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
|
||||
if (attrName != null && !attributeNames.Contains(attrName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
|
||||
$"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Expression-trigger scripts and alarms before deployment.
|
||||
///
|
||||
/// For every script/alarm whose trigger type is "Expression" this performs three
|
||||
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
|
||||
/// <list type="bullet">
|
||||
/// <item>Blank expression → warning (the trigger will never fire).</item>
|
||||
/// <item>Syntax check → error if the expression uses a forbidden API or has
|
||||
/// unbalanced brackets/quotes. The TemplateEngine project does not reference a
|
||||
/// Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
|
||||
/// string-based syntax check rather than a full compile.</item>
|
||||
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
|
||||
/// whose key is absent from the flattened configuration, mirroring
|
||||
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
var attributeNames = new HashSet<string>(
|
||||
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
if (!IsExpressionTrigger(script.TriggerType))
|
||||
continue;
|
||||
|
||||
CheckExpressionTrigger(
|
||||
ValidationCategory.ScriptTriggerReference, "script",
|
||||
script.CanonicalName, script.TriggerConfiguration,
|
||||
attributeNames, errors, warnings);
|
||||
}
|
||||
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
if (!IsExpressionTrigger(alarm.TriggerType))
|
||||
continue;
|
||||
|
||||
CheckExpressionTrigger(
|
||||
ValidationCategory.AlarmTriggerReference, "alarm",
|
||||
alarm.CanonicalName, alarm.TriggerConfiguration,
|
||||
attributeNames, errors, warnings);
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static bool IsExpressionTrigger(string? triggerType) =>
|
||||
string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the blank / syntax / attribute-reference checks for a single
|
||||
/// Expression-trigger entity and appends any findings to the shared lists.
|
||||
/// </summary>
|
||||
/// <param name="category">
|
||||
/// The <see cref="ValidationCategory"/> to file every finding under
|
||||
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
|
||||
/// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
|
||||
/// category is used for blank, syntax, and attribute-reference findings so an
|
||||
/// alarm's syntax error is not miscategorised as script compilation.
|
||||
/// </param>
|
||||
/// <param name="entityLabel">
|
||||
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
|
||||
/// message text only.
|
||||
/// </param>
|
||||
private static void CheckExpressionTrigger(
|
||||
ValidationCategory category,
|
||||
string entityLabel,
|
||||
string entityName,
|
||||
string? triggerConfigJson,
|
||||
HashSet<string> attributeNames,
|
||||
List<ValidationEntry> errors,
|
||||
List<ValidationEntry> warnings)
|
||||
{
|
||||
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(category,
|
||||
$"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
|
||||
entityName));
|
||||
return;
|
||||
}
|
||||
|
||||
var syntaxError = CheckExpressionSyntax(expression);
|
||||
if (syntaxError != null)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(category,
|
||||
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
|
||||
entityName));
|
||||
}
|
||||
|
||||
foreach (var attrName in ExtractAttributeReferences(expression))
|
||||
{
|
||||
if (!attributeNames.Contains(attrName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(category,
|
||||
$"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
entityName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
|
||||
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
|
||||
/// </summary>
|
||||
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
||||
internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(triggerConfigJson))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
if (doc.RootElement.TryGetProperty("expression", out var prop)
|
||||
&& prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — treated as a blank expression by the caller.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight string-based syntax check for a trigger expression. Mirrors the
|
||||
/// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
|
||||
/// Roslyn compiler reference): rejects forbidden APIs and unbalanced
|
||||
/// brackets/quotes. Returns an error message, or <c>null</c> when the expression
|
||||
/// looks well-formed.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to check for syntax errors.</param>
|
||||
internal static string? CheckExpressionSyntax(string expression)
|
||||
{
|
||||
// Advisory forbidden-API scan (TemplateEngine-006): code-region-aware so
|
||||
// the inert text inside a string/comment is not flagged, but still a
|
||||
// substring match — not an authoritative boundary. See ScriptCompiler.
|
||||
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
|
||||
{
|
||||
if (CSharpDelimiterScanner.ContainsInCode(expression, pattern))
|
||||
{
|
||||
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
|
||||
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
|
||||
}
|
||||
}
|
||||
|
||||
var parenDepth = 0;
|
||||
var bracketDepth = 0;
|
||||
var braceDepth = 0;
|
||||
var inString = false;
|
||||
var inChar = false;
|
||||
var inLineComment = false;
|
||||
var inBlockComment = false;
|
||||
|
||||
for (int i = 0; i < expression.Length; i++)
|
||||
{
|
||||
var c = expression[i];
|
||||
var next = i + 1 < expression.Length ? expression[i + 1] : '\0';
|
||||
|
||||
if (inLineComment)
|
||||
{
|
||||
if (c == '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment)
|
||||
{
|
||||
if (c == '*' && next == '/')
|
||||
{
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString)
|
||||
{
|
||||
if (c == '\\') { i++; continue; }
|
||||
if (c == '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inChar)
|
||||
{
|
||||
if (c == '\\') { i++; continue; }
|
||||
if (c == '\'') inChar = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"': inString = true; break;
|
||||
case '\'': inChar = true; break;
|
||||
case '(': parenDepth++; break;
|
||||
case ')':
|
||||
parenDepth--;
|
||||
if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
|
||||
break;
|
||||
case '[': bracketDepth++; break;
|
||||
case ']':
|
||||
bracketDepth--;
|
||||
if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
|
||||
break;
|
||||
case '{': braceDepth++; break;
|
||||
case '}':
|
||||
braceDepth--;
|
||||
if (braceDepth < 0) return "mismatched braces (unexpected '}').";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inBlockComment) return "unterminated block comment.";
|
||||
if (inString) return "unterminated string literal.";
|
||||
if (inChar) return "unterminated character literal.";
|
||||
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
|
||||
if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
|
||||
if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
|
||||
/// Best-effort: only matches double-quoted literals (the form the editor emits)
|
||||
/// and skips keys built dynamically.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to scan for attribute references.</param>
|
||||
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
const string marker = "Attributes[";
|
||||
var index = 0;
|
||||
|
||||
while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
// Only treat this as a self-attribute reference when it is not a member
|
||||
// access. A bare `Attributes["X"]` resolves against the flattened
|
||||
// configuration; `Children["Pump"].Attributes["X"]` and
|
||||
// `Parent.Attributes["X"]` are member accesses (preceded by '.') whose
|
||||
// dotted/composed canonical names cannot be checked against the flat
|
||||
// self-attribute set — skip them rather than emit a false positive.
|
||||
if (index > 0 && expression[index - 1] == '.')
|
||||
{
|
||||
index += marker.Length;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cursor = index + marker.Length;
|
||||
// Skip whitespace between '[' and the literal.
|
||||
while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
|
||||
cursor++;
|
||||
|
||||
if (cursor < expression.Length && expression[cursor] == '"')
|
||||
{
|
||||
var keyStart = cursor + 1;
|
||||
var keyEnd = keyStart;
|
||||
while (keyEnd < expression.Length && expression[keyEnd] != '"')
|
||||
{
|
||||
if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
|
||||
keyEnd++;
|
||||
}
|
||||
|
||||
if (keyEnd < expression.Length)
|
||||
{
|
||||
var key = expression.Substring(keyStart, keyEnd - keyStart);
|
||||
if (key.Length > 0 && seen.Add(key))
|
||||
yield return key;
|
||||
}
|
||||
}
|
||||
|
||||
index += marker.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all data-sourced attributes have connection bindings.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
foreach (var attr in configuration.Attributes)
|
||||
{
|
||||
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
|
||||
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
|
||||
attr.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static void CheckDuplicates<T>(
|
||||
IReadOnlyList<T> items,
|
||||
Func<T, string> getName,
|
||||
string entityType,
|
||||
List<ValidationEntry> errors)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in items)
|
||||
{
|
||||
var name = getName(item);
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
|
||||
$"{entityType} naming collision: '{name}' appears more than once.",
|
||||
name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the attribute name from a trigger configuration JSON.
|
||||
/// </summary>
|
||||
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
||||
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
|
||||
{
|
||||
// Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute,
|
||||
// AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use
|
||||
// "attribute"; the UI codec writes the canonical "attributeName".
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
|
||||
return prop.GetString();
|
||||
if (doc.RootElement.TryGetProperty("attribute", out var legacyProp))
|
||||
return legacyProp.GetString();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON, ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
|
||||
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
|
||||
/// all-nulls on malformed JSON — callers should treat that as "nothing to
|
||||
/// validate" and let other checks surface the deeper problem.
|
||||
/// </summary>
|
||||
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
|
||||
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
var root = doc.RootElement;
|
||||
return new HiLoSetpoints(
|
||||
LoLo: ReadDouble(root, "loLo"),
|
||||
Lo: ReadDouble(root, "lo"),
|
||||
Hi: ReadDouble(root, "hi"),
|
||||
HiHi: ReadDouble(root, "hiHi"),
|
||||
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: ReadDouble(root, "loDeadband"),
|
||||
HiDeadband: ReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ReadDouble(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(p.GetString(),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct HiLoSetpoints(
|
||||
double? LoLo, double? Lo, double? Hi, double? HiHi,
|
||||
double? LoLoDeadband = null, double? LoDeadband = null,
|
||||
double? HiDeadband = null, double? HiHiDeadband = null);
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user