38edfefc42
New TemplateChildEquality is the one place "has this template child changed?" is decided, comparing the COMPLETE writable field set per entity (attributes now incl. ElementDataType + LockedInDerived; alarms incl. the on-trigger script by name + LockedInDerived; scripts incl. MinTimeBetweenRuns/ExecutionTimeoutSeconds + LockedInDerived; native sources per T5). ArtifactDiff.CompareTemplate and all four BundleImporter.SyncTemplate*Async predicates now route through it — closing the class of bug where Preview reported Identical for an artifact an Overwrite would mutate. The attribute sync passes a value-normalised DTO so List-value re-imports stay idempotent; the alarm on-trigger change now surfaces in both diff and sync. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
727 lines
36 KiB
C#
727 lines
36 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
/// <summary>
|
|
/// Compares an incoming bundle DTO against the existing entity in the target
|
|
/// database and produces a single <see cref="ImportPreviewItem"/> that
|
|
/// classifies the conflict and (for <c>Modified</c>) carries a coarse field
|
|
/// diff in JSON.
|
|
/// <para>
|
|
/// "Coarse" means: each persistent field is compared as a value; differing
|
|
/// non-code fields appear in <c>changes</c> with old/new values. Code / large-
|
|
/// text fields (script bodies, API-method scripts) instead carry a structured
|
|
/// per-line Myers diff: the <see cref="FieldChange.OldValue"/> /
|
|
/// <see cref="FieldChange.NewValue"/> keep a compact <c><N lines></c>
|
|
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
|
|
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
|
|
/// truncation flag. The diff is size-capped via <see cref="LineDiffer"/>'s
|
|
/// <c>maxLines</c> cap so <see cref="ImportPreviewItem.FieldDiffJson"/> stays
|
|
/// bounded.
|
|
/// </para>
|
|
/// <para>
|
|
/// Entity versions are not yet tracked on the POCOs, so the
|
|
/// <see cref="ImportPreviewItem.ExistingVersion"/> / <see cref="ImportPreviewItem.IncomingVersion"/>
|
|
/// fields are always <c>null</c> here. They are reserved for a future
|
|
/// optimistic-concurrency feature.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ArtifactDiff
|
|
{
|
|
private static readonly JsonSerializerOptions DiffJsonOptions = new()
|
|
{
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
// ---- Templates ----
|
|
/// <summary>
|
|
/// Compares an incoming template against the existing template in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming template from the bundle.</param>
|
|
/// <param name="existing">The existing template in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null)
|
|
{
|
|
return New("Template", incoming.Name);
|
|
}
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
|
|
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
|
|
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
|
|
|
|
// Bundles reference the alarm on-trigger script by name; resolve the
|
|
// persisted FK back to a name over this template's own scripts so the
|
|
// shared equality can compare it. See TemplateChildEquality.
|
|
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
|
|
|
|
// Children: compare each child collection by name. We track which names
|
|
// were added, which were removed, and which existed on both sides but
|
|
// diverged in body. We use coarse value equality / line counts for
|
|
// scripts so the diff JSON stays under a few KB per item. Change
|
|
// detection is single-sourced through TemplateChildEquality so the diff
|
|
// can never disagree with what an Overwrite sync writes (#05-T6).
|
|
DiffChildren(
|
|
existing.Attributes,
|
|
incoming.Attributes,
|
|
e => e.Name,
|
|
i => i.Name,
|
|
TemplateChildEquality.AttributesEqual,
|
|
"Attributes",
|
|
changes);
|
|
|
|
DiffChildren(
|
|
existing.Alarms,
|
|
incoming.Alarms,
|
|
e => e.Name,
|
|
i => i.Name,
|
|
(e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById),
|
|
"Alarms",
|
|
changes);
|
|
|
|
DiffScriptChildren(existing.Scripts, incoming.Scripts, changes);
|
|
|
|
// Compositions diff by InstanceName since ComposedTemplateId vs
|
|
// ComposedTemplateName aren't directly comparable. The bundle side
|
|
// already serializes to the name form, so the comparison reduces to
|
|
// (InstanceName, ComposedTemplateName) tuple equality.
|
|
DiffChildren(
|
|
existing.Compositions,
|
|
incoming.Compositions,
|
|
e => e.InstanceName,
|
|
i => i.InstanceName,
|
|
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
|
|
"Compositions",
|
|
changes);
|
|
|
|
DiffChildren(
|
|
existing.NativeAlarmSources,
|
|
incoming.NativeAlarmSources,
|
|
e => e.Name,
|
|
i => i.Name,
|
|
TemplateChildEquality.NativeAlarmSourcesEqual,
|
|
"NativeAlarmSources",
|
|
changes);
|
|
|
|
return BuildItem("Template", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming shared script against the existing shared script in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming shared script from the bundle.</param>
|
|
/// <param name="existing">The existing shared script in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("SharedScript", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
|
|
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
|
|
AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code);
|
|
|
|
return BuildItem("SharedScript", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming external system against the existing external system in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming external system from the bundle.</param>
|
|
/// <param name="existing">The existing external system in the database, or null if new.</param>
|
|
/// <param name="existingMethods">The existing external system methods, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList<ExternalSystemMethod>? existingMethods)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("ExternalSystem", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl);
|
|
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
|
|
|
|
// Secrets: presence-only comparison (we never echo the value in the diff).
|
|
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration);
|
|
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration");
|
|
if (existingHasSecret != incomingHasSecret)
|
|
{
|
|
changes.Add(new FieldChange("Secrets.AuthConfiguration",
|
|
existingHasSecret ? "<present>" : null,
|
|
incomingHasSecret ? "<present>" : null));
|
|
}
|
|
|
|
// Methods are name-keyed children.
|
|
var existingForCompare = existingMethods ?? Array.Empty<ExternalSystemMethod>();
|
|
DiffChildren(
|
|
existingForCompare,
|
|
incoming.Methods,
|
|
e => e.Name,
|
|
i => i.Name,
|
|
ExternalSystemMethodsEqual,
|
|
"Methods",
|
|
changes);
|
|
|
|
return BuildItem("ExternalSystem", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming database connection against the existing database connection in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming database connection from the bundle.</param>
|
|
/// <param name="existing">The existing database connection in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("DatabaseConnection", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
|
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
|
|
|
// ConnectionString lives in Secrets only — presence-only comparison.
|
|
var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString);
|
|
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString");
|
|
if (existingHasSecret != incomingHasSecret)
|
|
{
|
|
changes.Add(new FieldChange("Secrets.ConnectionString",
|
|
existingHasSecret ? "<present>" : null,
|
|
incomingHasSecret ? "<present>" : null));
|
|
}
|
|
|
|
return BuildItem("DatabaseConnection", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming notification list against the existing notification list in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming notification list from the bundle.</param>
|
|
/// <param name="existing">The existing notification list in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("NotificationList", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString());
|
|
|
|
DiffChildren(
|
|
existing.Recipients,
|
|
incoming.Recipients,
|
|
e => e.Name,
|
|
i => i.Name,
|
|
// A recipient is unchanged only when BOTH contacts match — a phone
|
|
// number added/changed on an existing recipient is a real diff.
|
|
(e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
|
|
"Recipients",
|
|
changes);
|
|
|
|
return BuildItem("NotificationList", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming SMTP configuration against the existing SMTP configuration in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming SMTP configuration from the bundle.</param>
|
|
/// <param name="existing">The existing SMTP configuration in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("SmtpConfiguration", incoming.Host);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "Port", existing.Port, incoming.Port);
|
|
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
|
|
AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress);
|
|
AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode);
|
|
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
|
|
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
|
|
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
|
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
|
|
|
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
|
|
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
|
|
if (existingHasSecret != incomingHasSecret)
|
|
{
|
|
changes.Add(new FieldChange("Secrets.Credentials",
|
|
existingHasSecret ? "<present>" : null,
|
|
incomingHasSecret ? "<present>" : null));
|
|
}
|
|
|
|
return BuildItem("SmtpConfiguration", incoming.Host, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming SMS provider configuration against the existing one in
|
|
/// the database. Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
|
|
/// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
|
|
/// (it lives in <see cref="SecretsBlock"/>, never compared by value).
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming SMS configuration from the bundle.</param>
|
|
/// <param name="existing">The existing SMS configuration in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("SmsConfiguration", incoming.AccountSid);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber);
|
|
AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid);
|
|
AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl);
|
|
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
|
|
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
|
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
|
|
|
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken);
|
|
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken");
|
|
if (existingHasSecret != incomingHasSecret)
|
|
{
|
|
changes.Add(new FieldChange("Secrets.AuthToken",
|
|
existingHasSecret ? "<present>" : null,
|
|
incomingHasSecret ? "<present>" : null));
|
|
}
|
|
|
|
return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
|
|
}
|
|
|
|
// CompareApiKey was removed: inbound API keys are not transported
|
|
// between environments, so the import preview never diffs keys.
|
|
|
|
/// <summary>
|
|
/// Compares an incoming API method against the existing API method in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming API method from the bundle.</param>
|
|
/// <param name="existing">The existing API method in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("ApiMethod", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
// ApprovedApiKeyIds is not transported and is excluded from the diff.
|
|
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
|
|
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
|
|
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
|
|
AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script);
|
|
|
|
return BuildItem("ApiMethod", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming template folder against the existing template folder in the database.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming template folder from the bundle.</param>
|
|
/// <param name="existing">The existing template folder in the database, or null if new.</param>
|
|
/// <param name="folderNameById">A mapping of folder IDs to names for resolving parent folder references.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary<int, string> folderNameById)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("TemplateFolder", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder);
|
|
var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null;
|
|
AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName);
|
|
return BuildItem("TemplateFolder", incoming.Name, changes);
|
|
}
|
|
|
|
// ---- Site / Connection / Instance ----
|
|
|
|
/// <summary>
|
|
/// Compares an incoming site against the existing site in the database.
|
|
/// Identity is the <c>SiteIdentifier</c>; the diff is coarse over the
|
|
/// display name, description, and the four cluster/gRPC node addresses.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming site from the bundle.</param>
|
|
/// <param name="existing">The existing site in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("Site", incoming.SiteIdentifier);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "Name", existing.Name, incoming.Name);
|
|
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
|
|
AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress);
|
|
AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress);
|
|
AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress);
|
|
AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress);
|
|
|
|
return BuildItem("Site", incoming.SiteIdentifier, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming site-scoped data connection (the <c>Sites.DataConnection</c>
|
|
/// entity, NOT the External-System database connection) against the existing one.
|
|
/// The Primary/Backup protocol configuration lives in the DTO's
|
|
/// <see cref="SecretsBlock"/>, so it is compared <b>presence-only</b> — the diff
|
|
/// never echoes the configuration value, mirroring the external-system / DB-connection
|
|
/// secret handling.
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming data connection from the bundle.</param>
|
|
/// <param name="existing">The existing data connection in the database, or null if new.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("DataConnection", incoming.Name);
|
|
|
|
var changes = new List<FieldChange>();
|
|
AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol);
|
|
AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount);
|
|
|
|
// Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration).
|
|
// Presence-only comparison — never echo the config value.
|
|
AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration",
|
|
!string.IsNullOrEmpty(existing.PrimaryConfiguration),
|
|
HasSecretKey(incoming.Secrets, "PrimaryConfiguration"));
|
|
AddSecretPresenceChange(changes, "Secrets.BackupConfiguration",
|
|
!string.IsNullOrEmpty(existing.BackupConfiguration),
|
|
HasSecretKey(incoming.Secrets, "BackupConfiguration"));
|
|
|
|
return BuildItem("DataConnection", incoming.Name, changes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compares an incoming deployable instance against the existing one. Identity
|
|
/// is the <c>UniqueName</c>; the diff is coarse over the template/site/area/state
|
|
/// scalar fields plus a name-keyed child diff over the four override/binding
|
|
/// collections.
|
|
/// <para>
|
|
/// The caller is responsible for passing a <b>hydrated</b> <see cref="Instance"/>
|
|
/// (its <c>AttributeOverrides</c>, <c>AlarmOverrides</c>, <c>NativeAlarmSourceOverrides</c>,
|
|
/// and <c>ConnectionBindings</c> navigation collections eagerly loaded). A
|
|
/// non-hydrated entity reads as having no children, which would surface every
|
|
/// incoming child as an addition.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="incoming">The incoming instance from the bundle.</param>
|
|
/// <param name="existing">The existing (hydrated) instance in the database, or null if new.</param>
|
|
/// <param name="existingTemplateName">The existing instance's template name (the entity carries only a FK), or null if unknown.</param>
|
|
/// <param name="existingSiteIdentifier">The existing instance's site identifier (the entity carries only a FK), or null if unknown.</param>
|
|
/// <param name="existingAreaName">The existing instance's area name (the entity carries only a FK), or null if unknown / unset.</param>
|
|
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
|
public ImportPreviewItem CompareInstance(
|
|
InstanceDto incoming,
|
|
Instance? existing,
|
|
string? existingTemplateName = null,
|
|
string? existingSiteIdentifier = null,
|
|
string? existingAreaName = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(incoming);
|
|
if (existing is null) return New("Instance", incoming.UniqueName);
|
|
|
|
var changes = new List<FieldChange>();
|
|
// The entity stores template/site/area as numeric FKs that can't be compared
|
|
// cross-environment; the caller resolves them to names. When a name wasn't
|
|
// supplied we skip that scalar rather than emit a spurious "<id:N>" diff.
|
|
if (existingTemplateName is not null)
|
|
{
|
|
AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName);
|
|
}
|
|
if (existingSiteIdentifier is not null)
|
|
{
|
|
AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier);
|
|
}
|
|
AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName);
|
|
AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString());
|
|
|
|
DiffChildren(
|
|
existing.AttributeOverrides,
|
|
incoming.AttributeOverrides,
|
|
e => e.AttributeName,
|
|
i => i.AttributeName,
|
|
(e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType,
|
|
"AttributeOverrides",
|
|
changes);
|
|
|
|
DiffChildren(
|
|
existing.AlarmOverrides,
|
|
incoming.AlarmOverrides,
|
|
e => e.AlarmCanonicalName,
|
|
i => i.AlarmCanonicalName,
|
|
(e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
|
|
&& e.PriorityLevelOverride == i.PriorityLevelOverride,
|
|
"AlarmOverrides",
|
|
changes);
|
|
|
|
DiffChildren(
|
|
existing.NativeAlarmSourceOverrides,
|
|
incoming.NativeAlarmSourceOverrides,
|
|
e => e.SourceCanonicalName,
|
|
i => i.SourceCanonicalName,
|
|
(e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride
|
|
&& e.SourceReferenceOverride == i.SourceReferenceOverride
|
|
&& e.ConditionFilterOverride == i.ConditionFilterOverride,
|
|
"NativeAlarmSourceOverrides",
|
|
changes);
|
|
|
|
DiffChildren(
|
|
existing.ConnectionBindings,
|
|
incoming.ConnectionBindings,
|
|
e => e.AttributeName,
|
|
i => i.AttributeName,
|
|
// ConnectionName resolves to a FK on the entity (DataConnectionId) that
|
|
// can't be compared cross-environment, so the binding diff compares only
|
|
// the per-attribute DataSourceReference override.
|
|
(e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride,
|
|
"ConnectionBindings",
|
|
changes);
|
|
|
|
return BuildItem("Instance", incoming.UniqueName, changes);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static ImportPreviewItem New(string entityType, string name) =>
|
|
new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null);
|
|
|
|
private static ImportPreviewItem BuildItem(string entityType, string name, List<FieldChange> changes)
|
|
{
|
|
if (changes.Count == 0)
|
|
{
|
|
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null);
|
|
}
|
|
var diff = new FieldDiff(
|
|
Adds: Array.Empty<string>(),
|
|
Removes: Array.Empty<string>(),
|
|
Changes: changes);
|
|
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null);
|
|
}
|
|
|
|
private static void AddIfDifferent<T>(List<FieldChange> changes, string field, T existing, T incoming)
|
|
{
|
|
if (!Equals(existing, incoming))
|
|
{
|
|
changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString()));
|
|
}
|
|
}
|
|
|
|
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
|
|
{
|
|
// Code/large-text fields: when they differ, emit a real per-line Myers diff.
|
|
// The OldValue/NewValue keep a compact "<N lines>" summary as a
|
|
// fallback for renderers that don't consume the structured payload; the
|
|
// LineDiff carries the +/- hunk lines. Identical code emits no change.
|
|
var sameNullness = existing is null == incoming is null;
|
|
var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
|
|
if (bothPresentAndEqual) return;
|
|
|
|
changes.Add(BuildCodeFieldChange(field, existing, incoming));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="FieldChange"/> for a code/large-text field carrying both
|
|
/// the compact line-count summary and the structured per-line Myers diff. Only
|
|
/// ever called for the named code fields (TemplateScript.Code, SharedScript.Code,
|
|
/// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line
|
|
/// text emitted here is never sensitive.
|
|
/// </summary>
|
|
private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming)
|
|
{
|
|
var oldLines = existing?.Split('\n').Length ?? 0;
|
|
var newLines = incoming?.Split('\n').Length ?? 0;
|
|
// LineDiffer caps at maxLines (default 400); the payload's Truncated flag
|
|
// tells the UI when the hunk list was clipped so the JSON stays bounded.
|
|
var diff = LineDiffer.Diff(existing, incoming);
|
|
return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff));
|
|
}
|
|
|
|
private static LineDiffPayload ToPayload(LineDiffResult diff)
|
|
{
|
|
var hunks = new List<LineDiffHunk>(diff.Lines.Count);
|
|
foreach (var line in diff.Lines)
|
|
{
|
|
hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo));
|
|
}
|
|
return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount);
|
|
}
|
|
|
|
private static string OpName(LineDiffOp op) => op switch
|
|
{
|
|
LineDiffOp.Add => "add",
|
|
LineDiffOp.Remove => "remove",
|
|
_ => "context",
|
|
};
|
|
|
|
/// <summary>True if <paramref name="secrets"/> carries the named key.</summary>
|
|
private static bool HasSecretKey(SecretsBlock? secrets, string key) =>
|
|
secrets is not null && secrets.Values.ContainsKey(key);
|
|
|
|
/// <summary>
|
|
/// Records a presence-only secret change (a flip in whether a value is set on
|
|
/// each side) using the <c><present></c> / null marker convention. The
|
|
/// secret value itself is NEVER read into the diff — only its presence.
|
|
/// </summary>
|
|
private static void AddSecretPresenceChange(List<FieldChange> changes, string field, bool existingHasSecret, bool incomingHasSecret)
|
|
{
|
|
if (existingHasSecret != incomingHasSecret)
|
|
{
|
|
changes.Add(new FieldChange(field,
|
|
existingHasSecret ? "<present>" : null,
|
|
incomingHasSecret ? "<present>" : null));
|
|
}
|
|
}
|
|
|
|
private static void DiffChildren<TExisting, TIncoming>(
|
|
IEnumerable<TExisting> existing,
|
|
IEnumerable<TIncoming> incoming,
|
|
Func<TExisting, string> existingKey,
|
|
Func<TIncoming, string> incomingKey,
|
|
Func<TExisting, TIncoming, bool> equal,
|
|
string childCategory,
|
|
List<FieldChange> changes)
|
|
{
|
|
var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
|
|
var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
|
var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
|
|
|
foreach (var name in added)
|
|
{
|
|
changes.Add(new FieldChange($"{childCategory}.{name}", null, "<added>"));
|
|
}
|
|
foreach (var name in removed)
|
|
{
|
|
changes.Add(new FieldChange($"{childCategory}.{name}", "<present>", null));
|
|
}
|
|
foreach (var (name, e) in existingByName)
|
|
{
|
|
if (!incomingByName.TryGetValue(name, out var i)) continue;
|
|
if (!equal(e, i))
|
|
{
|
|
changes.Add(new FieldChange($"{childCategory}.{name}", "<modified>", "<modified>"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scripts get a finer-grained per-row line-count delta so the preview UI
|
|
/// can show the operator which scripts changed and roughly how much.
|
|
/// </summary>
|
|
private static void DiffScriptChildren(
|
|
IEnumerable<TemplateScript> existing,
|
|
IEnumerable<TemplateScriptDto> incoming,
|
|
List<FieldChange> changes)
|
|
{
|
|
var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
|
|
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
|
{
|
|
var inc = incomingByName[name];
|
|
var newLines = inc.Code.Split('\n').Length;
|
|
changes.Add(new FieldChange($"Scripts.{name}", null, $"<added, {newLines} lines>"));
|
|
}
|
|
foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
|
{
|
|
var ex = existingByName[name];
|
|
var oldLines = ex.Code.Split('\n').Length;
|
|
changes.Add(new FieldChange($"Scripts.{name}", $"<present, {oldLines} lines>", null));
|
|
}
|
|
foreach (var (name, ex) in existingByName)
|
|
{
|
|
if (!incomingByName.TryGetValue(name, out var inc)) continue;
|
|
if (!TemplateChildEquality.ScriptsEqual(ex, inc))
|
|
{
|
|
// A script row can diverge in Code and/or its trigger/param metadata.
|
|
// The structured line diff covers the Code body; when only the
|
|
// metadata changed the hunk list is all-context (no +/-), which still
|
|
// tells the operator the row changed.
|
|
changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
|
|
e.HttpMethod == i.HttpMethod
|
|
&& e.Path == i.Path
|
|
&& e.ParameterDefinitions == i.ParameterDefinitions
|
|
&& e.ReturnDefinition == i.ReturnDefinition;
|
|
|
|
private static string? FolderNameOf(Template t)
|
|
{
|
|
// Templates carry only a FK to the folder; the EntitySerializer projects
|
|
// it to a name. The diff doesn't have access to a folder lookup at
|
|
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
|
|
// known. The PreviewAsync caller can pass a hydrated Template (via
|
|
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
|
|
// it — this branch is a deliberate best-effort.
|
|
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
|
|
}
|
|
|
|
private static string? BaseTemplateNameOf(Template t)
|
|
{
|
|
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
|
|
}
|
|
|
|
private static string CompositionTargetNameOf(TemplateComposition comp)
|
|
{
|
|
return $"<id:{comp.ComposedTemplateId}>";
|
|
}
|
|
|
|
private sealed record FieldChange(
|
|
[property: JsonPropertyName("field")] string Field,
|
|
[property: JsonPropertyName("oldValue")] string? OldValue,
|
|
[property: JsonPropertyName("newValue")] string? NewValue,
|
|
// Present only for code / large-text fields. Null (and omitted from
|
|
// the JSON by the WhenWritingNull policy) for ordinary coarse fields.
|
|
[property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null);
|
|
|
|
/// <summary>
|
|
/// Serializable projection of <see cref="LineDiffResult"/> embedded inside a
|
|
/// code <see cref="FieldChange"/>. <see cref="Hunks"/> is in source order and
|
|
/// may be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> /
|
|
/// <see cref="RemovedCount"/> always report the full diff totals. The UI (E2)
|
|
/// renders the +/- view directly from <see cref="Hunks"/>.
|
|
/// </summary>
|
|
private sealed record LineDiffPayload(
|
|
[property: JsonPropertyName("hunks")] IReadOnlyList<LineDiffHunk> Hunks,
|
|
[property: JsonPropertyName("truncated")] bool Truncated,
|
|
[property: JsonPropertyName("addedCount")] int AddedCount,
|
|
[property: JsonPropertyName("removedCount")] int RemovedCount);
|
|
|
|
/// <summary>
|
|
/// One emitted line of a code line-diff. <see cref="Op"/> is the lower-case
|
|
/// op name (<c>context</c> / <c>add</c> / <c>remove</c>); line numbers are
|
|
/// 1-based and null where the side does not participate (an <c>add</c> has no
|
|
/// old line number, a <c>remove</c> has no new line number).
|
|
/// </summary>
|
|
private sealed record LineDiffHunk(
|
|
[property: JsonPropertyName("op")] string Op,
|
|
[property: JsonPropertyName("text")] string Text,
|
|
[property: JsonPropertyName("oldLineNo")] int? OldLineNo,
|
|
[property: JsonPropertyName("newLineNo")] int? NewLineNo);
|
|
|
|
private sealed record FieldDiff(
|
|
[property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
|
|
[property: JsonPropertyName("removes")] IReadOnlyList<string> Removes,
|
|
[property: JsonPropertyName("changes")] IReadOnlyList<FieldChange> Changes);
|
|
}
|