Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||
/// Same content always produces the same hash across runs by using
|
||||
/// canonical JSON serialization with sorted keys.
|
||||
/// </summary>
|
||||
public class RevisionHashService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
// Sort properties alphabetically for determinism
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
// Ensure consistent ordering
|
||||
Converters = { new SortedPropertiesConverterFactory() }
|
||||
};
|
||||
|
||||
/// <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>
|
||||
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,
|
||||
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,
|
||||
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()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
// Internal types for deterministic serialization (sorted property names via camelCase + alphabetical)
|
||||
private sealed record HashableConfiguration
|
||||
{
|
||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||
public int? AreaId { get; init; }
|
||||
public List<HashableAttribute> Attributes { get; init; } = [];
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public List<HashableScript> Scripts { get; init; } = [];
|
||||
public int SiteId { get; init; }
|
||||
public int TemplateId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAttribute
|
||||
{
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string? DataSourceReference { get; init; }
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAlarm
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
public int PriorityLevel { get; init; }
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableScript
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public long? MinTimeBetweenRunsTicks { get; init; }
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
public string? ReturnDefinition { get; init; }
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
public string? TriggerType { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A JSON converter factory that ensures properties are serialized in alphabetical order
|
||||
/// for deterministic output. Works with record types.
|
||||
/// </summary>
|
||||
internal class SortedPropertiesConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert) => false;
|
||||
|
||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
|
||||
}
|
||||
Reference in New Issue
Block a user