fix(correctness): close Theme 10 — 5 data-integrity / serialisation findings
Final themed batch. 5 well-localised correctness fixes. Serialisation precision: - ESG-020: DatabaseGateway.JsonElementToParameterValue probes TryGetInt64 → TryGetDecimal → GetDouble, so a script's high-precision decimal SQL parameter survives the cached-write retry round-trip without silent precision loss. 3 new regression tests. Template engine correctness: - TE-018: DiffService gains ComputeConnectionsDiff over FlattenedConfiguration.Connections, mirroring the existing entity-diff shape and pairing with the Theme 1 TE-017 hash-coverage fix. A ConfigurationDiff record extension in Commons is flagged as a follow-up. - TE-019: TemplateResolver.BuildInheritanceChain now walks via the int? ParentTemplateId directly — only null means "no parent". A real Id of 0 (the prior special-cased sentinel) now walks the chain like any other node, matching the TemplateEngine-013 CycleDetector fix. Regression of TE-013 closed. - TE-020: All 5 Create* paths in TemplateService + SharedScriptService re-ordered to save-first → log-with-real-Id → save-audit (matching the InstanceService pattern). Create* audit rows no longer carry a literal "0" EntityId. Doc deferral: - Transport-012: Component-Transport.md §Audit Trail now spells out that the BundleImportId repository filter IS wired (in CentralUiRepository), but the Audit-Log-Viewer UI dropdown + summary-row hyperlink are a deferred CentralUI follow-up. CLI workaround documented (audit query --bundle-import-id). 11+ new regression tests (3 ESG, 4 DiffService, 3 TemplateResolver, 4 TemplateService, 1 SharedScriptService). Build clean; ESG 72/72, TemplateEngine 324/324. README regenerated: 1 pending of 481 total. Session-to-date: 135 of 136 originally-open Theme findings closed across 10 themes in 10 commits.
This commit is contained in:
@@ -201,10 +201,23 @@ public class DatabaseGateway : IDatabaseGateway
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
||||
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must
|
||||
// prefer decimal over double — a script's decimal SQL parameter is
|
||||
// serialised as JSON without a type tag, and downcasting it to double on
|
||||
// the cached-write retry path silently loses precision (e.g.
|
||||
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
|
||||
// long first (whole-number fast path), then decimal (preserves authored
|
||||
// precision for typical money/measurement values), and only fall through
|
||||
// to double for genuinely out-of-decimal-range values (very large
|
||||
// scientific-notation floats).
|
||||
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l)
|
||||
? l
|
||||
: element.TryGetDecimal(out var dec)
|
||||
? dec
|
||||
: element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
|
||||
|
||||
@@ -139,10 +139,7 @@ public class DiffService
|
||||
/// 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; the public diff shape only
|
||||
/// exposes attribute / alarm / script changes today (see TemplateEngine-018
|
||||
/// for the diff-shape extension that surfaces added / removed / changed
|
||||
/// connections in the UI).
|
||||
/// need to detect connection-endpoint drift.
|
||||
/// </summary>
|
||||
/// <param name="a">First connection configuration.</param>
|
||||
/// <param name="b">Second connection configuration.</param>
|
||||
@@ -157,4 +154,52 @@ public class DiffService
|
||||
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>ScadaLink.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,14 @@ public class SharedScriptService
|
||||
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 _auditService.LogAsync(user, "Create", "SharedScript", "0", name, 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);
|
||||
|
||||
@@ -103,6 +103,16 @@ public static class TemplateResolver
|
||||
/// <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>
|
||||
@@ -111,16 +121,16 @@ public static class TemplateResolver
|
||||
IReadOnlyDictionary<int, Template> lookup)
|
||||
{
|
||||
var chain = new List<Template>();
|
||||
var currentId = templateId;
|
||||
int? currentId = templateId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
|
||||
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
|
||||
{
|
||||
if (!visited.Add(currentId))
|
||||
if (!visited.Add(currentId.Value))
|
||||
break; // Safety: cycle detected
|
||||
|
||||
chain.Add(current);
|
||||
currentId = current.ParentTemplateId ?? 0;
|
||||
currentId = current.ParentTemplateId;
|
||||
}
|
||||
|
||||
chain.Reverse(); // Root first
|
||||
|
||||
@@ -73,8 +73,15 @@ public class TemplateService
|
||||
// collisions are enforced on every member-mutating call (AddAttribute,
|
||||
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
|
||||
|
||||
// TemplateEngine-020: save the entity first so EF Core populates the
|
||||
// auto-generated key, then write the audit row with the real
|
||||
// template.Id, then save the audit row. The pre-fix order logged
|
||||
// EntityId = "0" because the audit row was queued before
|
||||
// SaveChangesAsync ran — every Create audit entry lost the link back
|
||||
// to the row it described.
|
||||
await _repository.AddTemplateAsync(template, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<Template>.Success(template);
|
||||
@@ -277,8 +284,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real attribute Id rather than a literal "0".
|
||||
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(attribute);
|
||||
@@ -441,8 +451,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real alarm Id rather than a literal "0".
|
||||
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(alarm);
|
||||
@@ -599,8 +612,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real script Id rather than a literal "0".
|
||||
await _repository.AddTemplateScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(script);
|
||||
@@ -787,8 +803,12 @@ public class TemplateService
|
||||
// No global name pre-check: derived templates store their contained
|
||||
// (slot) name, which need only be unique within the owner — and that is
|
||||
// already enforced above and by the (TemplateId, InstanceName) index.
|
||||
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
|
||||
// composition row, so composition.Id is populated by the time control
|
||||
// returns here — the audit row therefore carries the real Id instead
|
||||
// of the pre-fix literal "0".
|
||||
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateComposition>.Success(composition);
|
||||
|
||||
Reference in New Issue
Block a user