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,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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user