feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18)
This commit is contained in:
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
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;
|
||||
@@ -863,11 +864,17 @@ public sealed class BundleImporter : IBundleImporter
|
||||
Guid sessionId,
|
||||
IReadOnlyList<ImportResolution> resolutions,
|
||||
string user,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default,
|
||||
BundleNameMap? nameMap = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolutions);
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
// Normalise null → Empty so the resolve-or-create logic always has a map
|
||||
// to consult. An empty map means "no explicit operator mappings" — every
|
||||
// referenced site/connection falls through to the identity auto-match.
|
||||
nameMap ??= BundleNameMap.Empty;
|
||||
|
||||
var session = _sessionStore.Get(sessionId)
|
||||
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
|
||||
if (session.Locked)
|
||||
@@ -929,11 +936,42 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// a Skip on a dependency surfaces as a missing-reference error
|
||||
// rather than silently passing.
|
||||
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||||
// M8: validate every site / connection / template reference the
|
||||
// site-instance payload depends on BEFORE any row is staged. Running
|
||||
// this in the validation phase (not as an apply-pass guard) preserves
|
||||
// the rollback contract: a structurally-unresolvable bundle fails with
|
||||
// an empty change tracker, so nothing is half-written on the in-memory
|
||||
// provider (where the intermediate site/connection flush can't be
|
||||
// undone by ChangeTracker.Clear). The apply passes keep defensive
|
||||
// guards, but in normal operation those never fire.
|
||||
validationErrors = validationErrors.Count > 0
|
||||
? validationErrors
|
||||
: await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
throw new SemanticValidationException(validationErrors);
|
||||
}
|
||||
|
||||
// ---- M8 site/instance-scoped apply: sites + connections FIRST ----
|
||||
// Sites are the FK target for both data connections (SiteId) and
|
||||
// instances (SiteId); data connections are the FK target for instance
|
||||
// connection bindings (DataConnectionId) and the rewrite target for
|
||||
// native-alarm-source ConnectionNameOverride. Resolve-or-create both
|
||||
// BEFORE the central-config apply so the maps the instance pass needs
|
||||
// are fully populated, then flush so newly-created site/connection ids
|
||||
// materialise on the relational provider. (The instance pass itself
|
||||
// runs AFTER the central-config flush so it can also resolve template
|
||||
// ids by name.)
|
||||
var siteBySourceIdentifier = await ApplySitesAsync(
|
||||
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
var connectionMaps = await ApplyDataConnectionsAsync(
|
||||
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
// Flush so site + connection surrogate ids are assigned (relational
|
||||
// provider) before the instance pass wires its FKs. In-memory assigns
|
||||
// ids on AddAsync, so this is mostly a no-op there, but it keeps the
|
||||
// ordering correct on a real DB. Rides the same outer transaction.
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||
@@ -964,6 +1002,31 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||||
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||||
|
||||
// ---- M8 site/instance-scoped apply: instances LAST ----
|
||||
// The instance pass needs every FK target materialised first:
|
||||
// • template id — resolved by name against the just-flushed central
|
||||
// config (Templates were flushed above for the alarm/composition
|
||||
// rewire), plus pre-existing target templates;
|
||||
// • site id — from the site map built by ApplySitesAsync;
|
||||
// • connection id — from the connection map built by
|
||||
// ApplyDataConnectionsAsync.
|
||||
// It writes the instance row + its four child collections (attribute /
|
||||
// alarm / native-alarm-source overrides + connection bindings) and
|
||||
// rewires every name-keyed FK to the target environment's ids.
|
||||
await ApplyInstancesAsync(
|
||||
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
|
||||
user, summary, ct).ConfigureAwait(false);
|
||||
|
||||
// ---- D2 pre-commit point (#16): stale-instance computation ----
|
||||
// Everything the import will write is staged on the change tracker at
|
||||
// this point but NOT yet committed. D2 computes StaleInstanceIds here
|
||||
// — target instances whose template was overwritten by this import and
|
||||
// therefore now carry a stale flattened-config revision — and threads
|
||||
// the resulting list into the ImportResult below (replacing the
|
||||
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
|
||||
// is the next statement, so a read against the change tracker here sees
|
||||
// the full post-apply graph before commit.
|
||||
|
||||
await _auditService.LogAsync(
|
||||
user: user,
|
||||
action: "BundleImported",
|
||||
@@ -2387,6 +2450,784 @@ public sealed class BundleImporter : IBundleImporter
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// M8 D1 — site/instance-scoped apply.
|
||||
//
|
||||
// Three passes (sites → connections → instances) resolve every cross-
|
||||
// environment name reference against the TARGET database's own surrogate
|
||||
// keys, honouring the operator-supplied BundleNameMap with an identity
|
||||
// auto-match fallback for references the map doesn't mention. Sites and
|
||||
// connections run before the central-config apply (they are FK targets);
|
||||
// instances run after the central-config flush (they additionally need
|
||||
// template ids by name). All three ride the single outer transaction begun
|
||||
// in ApplyAsync, so a throw anywhere rolls back the whole import.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Resolve-or-create every target <see cref="Site"/> the bundle references,
|
||||
/// returning a <c>sourceSiteIdentifier → target Site</c> map (each value
|
||||
/// carries the target environment's surrogate <see cref="Site.Id"/>).
|
||||
/// <para>
|
||||
/// A site's mapping is taken from <paramref name="nameMap"/> (matched by
|
||||
/// <see cref="SiteMapping.SourceSiteIdentifier"/>); when the bundle carries
|
||||
/// no explicit entry we auto-match by identity — an existing target site with
|
||||
/// the same identifier resolves to <see cref="MappingAction.MapToExisting"/>,
|
||||
/// otherwise <see cref="MappingAction.CreateNew"/>. <c>CreateNew</c> inserts a
|
||||
/// site from the full <see cref="SiteDto"/> payload (display name, description,
|
||||
/// and the verbatim Node A/B + gRPC Node A/B addresses — D3's "carry full
|
||||
/// config" decision). <c>MapToExisting</c> honours the site's
|
||||
/// <see cref="ImportResolution"/>: <see cref="ResolutionAction.Skip"/> leaves
|
||||
/// the target untouched; <see cref="ResolutionAction.Overwrite"/> applies the
|
||||
/// bundle's fields onto the existing row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sites that are <em>referenced</em> by an instance (or data connection) but
|
||||
/// not carried in <see cref="BundleContentDto.Sites"/> must already exist in
|
||||
/// the target — they auto-match to the existing target site. An unresolvable
|
||||
/// reference is a hard error (caught at preview as a blocker; guarded
|
||||
/// defensively here so a stale resolution payload can't write an orphan FK).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, Site>> ApplySitesAsync(
|
||||
BundleContentDto content,
|
||||
BundleNameMap nameMap,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
string user,
|
||||
ImportSummary summary,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<string, Site>(StringComparer.Ordinal);
|
||||
var siteMappingByIdentifier = nameMap.Sites
|
||||
.GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
// Pass 1 — sites the bundle CARRIES (full SiteDto payload available).
|
||||
foreach (var siteDto in content.Sites)
|
||||
{
|
||||
if (result.ContainsKey(siteDto.SiteIdentifier)) continue;
|
||||
|
||||
siteMappingByIdentifier.TryGetValue(siteDto.SiteIdentifier, out var mapping);
|
||||
var existing = await _siteRepo
|
||||
.GetSiteByIdentifierAsync(siteDto.SiteIdentifier, ct).ConfigureAwait(false);
|
||||
|
||||
// Auto-match when the operator didn't supply an explicit mapping:
|
||||
// existing target → MapToExisting, otherwise CreateNew.
|
||||
var action = mapping?.Action
|
||||
?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
|
||||
|
||||
// An explicit MapToExisting can target a DIFFERENTLY-named site; honour
|
||||
// the operator's TargetSiteIdentifier when supplied, else fall back to
|
||||
// the same-identifier match.
|
||||
Site? target = existing;
|
||||
if (action == MappingAction.MapToExisting
|
||||
&& mapping?.TargetSiteIdentifier is { Length: > 0 } targetId
|
||||
&& !string.Equals(targetId, siteDto.SiteIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
target = await _siteRepo.GetSiteByIdentifierAsync(targetId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (action == MappingAction.CreateNew || target is null)
|
||||
{
|
||||
var created = BuildSite(siteDto);
|
||||
await _siteRepo.AddSiteAsync(created, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Create", "Site", "0", created.SiteIdentifier,
|
||||
new { created.SiteIdentifier, created.Name }, ct).ConfigureAwait(false);
|
||||
summary.Added++;
|
||||
result[siteDto.SiteIdentifier] = created;
|
||||
continue;
|
||||
}
|
||||
|
||||
// MapToExisting — honour the site's own conflict resolution.
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Site", siteDto.SiteIdentifier);
|
||||
if (resolution.Action == ResolutionAction.Overwrite)
|
||||
{
|
||||
ApplySiteFields(target, siteDto);
|
||||
await _siteRepo.UpdateSiteAsync(target, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Update", "Site", target.Id.ToString(), target.SiteIdentifier,
|
||||
new { target.SiteIdentifier, target.Name }, ct).ConfigureAwait(false);
|
||||
summary.Overwritten++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Skip / Add(no-op for existing) — leave the target untouched.
|
||||
summary.Skipped++;
|
||||
}
|
||||
result[siteDto.SiteIdentifier] = target;
|
||||
}
|
||||
|
||||
// Pass 2 — sites REFERENCED but not carried (instances / connections).
|
||||
// These must already exist in the target (no DTO to create from). Auto-
|
||||
// match by identity; honour an explicit MapToExisting redirect.
|
||||
foreach (var identifier in EnumerateReferencedSiteIdentifiers(content))
|
||||
{
|
||||
if (result.ContainsKey(identifier)) continue;
|
||||
|
||||
siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
|
||||
var lookupId = mapping?.Action == MappingAction.MapToExisting
|
||||
&& mapping.TargetSiteIdentifier is { Length: > 0 } t
|
||||
? t
|
||||
: identifier;
|
||||
var target = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
|
||||
if (target is null)
|
||||
{
|
||||
// Defensive guard — preview surfaces this as a blocker, but a stale
|
||||
// resolution payload could still reach apply. Fail the whole import
|
||||
// rather than write an instance/connection with an orphan SiteId.
|
||||
throw new InvalidOperationException(
|
||||
$"Site '{identifier}' is referenced by the bundle but not present in the target "
|
||||
+ "and not carried in the bundle — cannot resolve a target site.");
|
||||
}
|
||||
result[identifier] = target;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct source-site identifiers referenced by instances and data
|
||||
/// connections but not necessarily carried as a <see cref="SiteDto"/>.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> EnumerateReferencedSiteIdentifiers(BundleContentDto content)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var i in content.Instances)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(i.SiteIdentifier) && seen.Add(i.SiteIdentifier))
|
||||
yield return i.SiteIdentifier;
|
||||
}
|
||||
foreach (var dc in content.DataConnections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(dc.SiteIdentifier) && seen.Add(dc.SiteIdentifier))
|
||||
yield return dc.SiteIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
private static Site BuildSite(SiteDto dto) => new(dto.Name, dto.SiteIdentifier)
|
||||
{
|
||||
Description = dto.Description,
|
||||
NodeAAddress = dto.NodeAAddress,
|
||||
NodeBAddress = dto.NodeBAddress,
|
||||
GrpcNodeAAddress = dto.GrpcNodeAAddress,
|
||||
GrpcNodeBAddress = dto.GrpcNodeBAddress,
|
||||
};
|
||||
|
||||
private static void ApplySiteFields(Site target, SiteDto dto)
|
||||
{
|
||||
target.Name = dto.Name;
|
||||
target.Description = dto.Description;
|
||||
target.NodeAAddress = dto.NodeAAddress;
|
||||
target.NodeBAddress = dto.NodeBAddress;
|
||||
target.GrpcNodeAAddress = dto.GrpcNodeAAddress;
|
||||
target.GrpcNodeBAddress = dto.GrpcNodeBAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The resolved-connection maps the instance pass needs:
|
||||
/// <see cref="IdBySourceRef"/> rewires connection-binding FKs
|
||||
/// (<c>(sourceSite, sourceName) → target DataConnectionId</c>);
|
||||
/// <see cref="TargetNameBySourceRef"/> rewrites native-alarm-source
|
||||
/// <c>ConnectionNameOverride</c> values to the MAPPED target connection name
|
||||
/// (<c>(sourceSite, sourceName) → target connection Name</c>) so a
|
||||
/// differently-named MapToExisting redirect carries through.
|
||||
/// </summary>
|
||||
private readonly record struct ResolvedConnectionMaps(
|
||||
Dictionary<(string Site, string Name), int> IdBySourceRef,
|
||||
Dictionary<(string Site, string Name), string> TargetNameBySourceRef);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve-or-create every target <see cref="DataConnection"/> the bundle
|
||||
/// references, returning the id + target-name maps (see
|
||||
/// <see cref="ResolvedConnectionMaps"/>) the instance pass uses to rewire
|
||||
/// connection-binding FKs and native-alarm-source <c>ConnectionNameOverride</c>
|
||||
/// rewrites.
|
||||
/// <para>
|
||||
/// A connection's mapping is taken from <paramref name="nameMap"/> (matched by
|
||||
/// <see cref="ConnectionMapping.SourceSiteIdentifier"/> +
|
||||
/// <see cref="ConnectionMapping.SourceConnectionName"/>); with no explicit
|
||||
/// entry we auto-match by name WITHIN the mapped target site. <c>CreateNew</c>
|
||||
/// inserts a connection under the mapped target site, restoring
|
||||
/// <c>PrimaryConfiguration</c> / <c>BackupConfiguration</c> from the DTO's
|
||||
/// <see cref="SecretsBlock"/>. <c>MapToExisting</c> honours the connection's
|
||||
/// <see cref="ImportResolution"/> (Overwrite applies the bundle fields; Skip
|
||||
/// leaves the target row untouched).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<ResolvedConnectionMaps> ApplyDataConnectionsAsync(
|
||||
BundleContentDto content,
|
||||
BundleNameMap nameMap,
|
||||
Dictionary<string, Site> siteBySourceIdentifier,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
string user,
|
||||
ImportSummary summary,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<(string, string), int>();
|
||||
var targetNameByRef = new Dictionary<(string Site, string Name), string>();
|
||||
var connMappingByRef = nameMap.Connections
|
||||
.GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// Memoise the target site's existing connections (one query per site).
|
||||
var targetConnsBySiteId = new Dictionary<int, IReadOnlyList<DataConnection>>();
|
||||
async Task<IReadOnlyList<DataConnection>> TargetConnsAsync(int siteId)
|
||||
{
|
||||
if (targetConnsBySiteId.TryGetValue(siteId, out var cached)) return cached;
|
||||
var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, ct).ConfigureAwait(false);
|
||||
targetConnsBySiteId[siteId] = conns;
|
||||
return conns;
|
||||
}
|
||||
|
||||
foreach (var dcDto in content.DataConnections)
|
||||
{
|
||||
var key = (dcDto.SiteIdentifier, dcDto.Name);
|
||||
if (result.ContainsKey(key)) continue;
|
||||
|
||||
if (!siteBySourceIdentifier.TryGetValue(dcDto.SiteIdentifier, out var targetSite))
|
||||
{
|
||||
// Should never happen: ApplySitesAsync resolved every referenced
|
||||
// site (including those only referenced by a connection). Guard so a
|
||||
// missing entry fails the import instead of writing an orphan FK.
|
||||
throw new InvalidOperationException(
|
||||
$"Data connection '{dcDto.SiteIdentifier}/{dcDto.Name}' references a site that "
|
||||
+ "could not be resolved to a target.");
|
||||
}
|
||||
|
||||
connMappingByRef.TryGetValue(key, out var mapping);
|
||||
var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
|
||||
|
||||
// The connection name to match in the target — an explicit
|
||||
// MapToExisting may redirect to a differently-named target connection.
|
||||
var targetName = mapping?.Action == MappingAction.MapToExisting
|
||||
&& mapping.TargetConnectionName is { Length: > 0 } tn
|
||||
? tn
|
||||
: dcDto.Name;
|
||||
var existing = targetConns.FirstOrDefault(c =>
|
||||
string.Equals(c.Name, targetName, StringComparison.Ordinal));
|
||||
|
||||
var action = mapping?.Action
|
||||
?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
|
||||
|
||||
if (action == MappingAction.CreateNew || existing is null)
|
||||
{
|
||||
var created = BuildDataConnection(dcDto, targetSite.Id);
|
||||
await _siteRepo.AddDataConnectionAsync(created, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Create", "DataConnection", "0",
|
||||
$"{targetSite.SiteIdentifier}/{created.Name}",
|
||||
new { created.Name, created.Protocol, SiteIdentifier = targetSite.SiteIdentifier }, ct)
|
||||
.ConfigureAwait(false);
|
||||
summary.Added++;
|
||||
result[key] = created.Id;
|
||||
targetNameByRef[key] = created.Name;
|
||||
continue;
|
||||
}
|
||||
|
||||
// MapToExisting — honour the connection's own conflict resolution.
|
||||
var resolution = ResolveOrDefault(
|
||||
resolutionMap, "DataConnection", dcDto.Name);
|
||||
if (resolution.Action == ResolutionAction.Overwrite)
|
||||
{
|
||||
ApplyDataConnectionFields(existing, dcDto);
|
||||
await _siteRepo.UpdateDataConnectionAsync(existing, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Update", "DataConnection", existing.Id.ToString(),
|
||||
$"{targetSite.SiteIdentifier}/{existing.Name}",
|
||||
new { existing.Name, existing.Protocol }, ct).ConfigureAwait(false);
|
||||
summary.Overwritten++;
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.Skipped++;
|
||||
}
|
||||
result[key] = existing.Id;
|
||||
// existing.Name is the TARGET connection name — for a same-name match
|
||||
// it equals dcDto.Name, for a redirect it's the redirected target.
|
||||
targetNameByRef[key] = existing.Name;
|
||||
}
|
||||
|
||||
return new ResolvedConnectionMaps(result, targetNameByRef);
|
||||
}
|
||||
|
||||
private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
|
||||
new(dto.Name, dto.Protocol, siteId)
|
||||
{
|
||||
FailoverRetryCount = dto.FailoverRetryCount,
|
||||
PrimaryConfiguration =
|
||||
dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
|
||||
BackupConfiguration =
|
||||
dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
|
||||
};
|
||||
|
||||
private static void ApplyDataConnectionFields(DataConnection target, DataConnectionDto dto)
|
||||
{
|
||||
target.Protocol = dto.Protocol;
|
||||
target.FailoverRetryCount = dto.FailoverRetryCount;
|
||||
target.PrimaryConfiguration =
|
||||
dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null;
|
||||
target.BackupConfiguration =
|
||||
dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upsert every <see cref="InstanceDto"/> in the bundle, rewiring its
|
||||
/// cross-environment name references to the target's surrogate keys:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TemplateName</c> → target template id (just-imported set first,
|
||||
/// then pre-existing target); an unresolved template was a preview blocker —
|
||||
/// guarded here so a stale payload fails the import rather than writing an
|
||||
/// orphan FK.</item>
|
||||
/// <item><c>SiteIdentifier</c> → target site id (from the site map).</item>
|
||||
/// <item><c>AreaName</c> → an area under the target site, created if missing.</item>
|
||||
/// <item>each <c>ConnectionBinding.ConnectionName</c> → target
|
||||
/// <c>DataConnectionId</c> (from the connection map).</item>
|
||||
/// <item>each <c>NativeAlarmSourceOverride.ConnectionNameOverride</c> →
|
||||
/// rewritten to the MAPPED target connection name.</item>
|
||||
/// </list>
|
||||
/// Imported instances are always written with <see cref="InstanceState.NotDeployed"/>
|
||||
/// — an imported instance is design-time configuration, never carried as
|
||||
/// live/deployed across environments. Identity is the <c>UniqueName</c>
|
||||
/// (hydrated via <see cref="ITemplateEngineRepository.GetInstanceByUniqueNameAsync"/>,
|
||||
/// which eager-loads all four child collections). The instance's
|
||||
/// <see cref="ImportResolution"/> drives Add / Overwrite / Skip / Rename; on
|
||||
/// Overwrite the existing child rows are deleted-then-readded so the bundle is
|
||||
/// the source of truth (mirrors the template-overwrite child-sync pattern).
|
||||
/// </summary>
|
||||
private async Task ApplyInstancesAsync(
|
||||
BundleContentDto content,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
Dictionary<string, Site> siteBySourceIdentifier,
|
||||
ResolvedConnectionMaps connectionMaps,
|
||||
string user,
|
||||
ImportSummary summary,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (content.Instances.Count == 0) return;
|
||||
|
||||
// Build a target-template name→id map once (just-imported templates were
|
||||
// flushed before this pass; pre-existing target templates count too).
|
||||
var templateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||||
.GroupBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
|
||||
|
||||
// Memoise area resolution per (siteId, areaName) so two instances under the
|
||||
// same area don't each create a duplicate row.
|
||||
var areaIdByKey = new Dictionary<(int SiteId, string Name), int>();
|
||||
|
||||
foreach (var dto in content.Instances)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Instance", dto.UniqueName);
|
||||
if (resolution.Action == ResolutionAction.Skip)
|
||||
{
|
||||
summary.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the template id by name (post-rename: an imported template
|
||||
// resolved as Rename was written under RenameTo, which is what the
|
||||
// instance's TemplateName must already match in a self-consistent
|
||||
// bundle; v1 does not rewrite instance TemplateName references).
|
||||
if (!templateIdByName.TryGetValue(dto.TemplateName, out var templateId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Instance '{dto.UniqueName}' references template '{dto.TemplateName}' which is "
|
||||
+ "present in neither the bundle nor the target.");
|
||||
}
|
||||
|
||||
if (!siteBySourceIdentifier.TryGetValue(dto.SiteIdentifier, out var targetSite))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Instance '{dto.UniqueName}' references site '{dto.SiteIdentifier}' which could "
|
||||
+ "not be resolved to a target.");
|
||||
}
|
||||
|
||||
int? areaId = await ResolveOrCreateAreaIdAsync(
|
||||
dto.AreaName, targetSite.Id, areaIdByKey, ct).ConfigureAwait(false);
|
||||
|
||||
switch (resolution.Action)
|
||||
{
|
||||
case ResolutionAction.Rename:
|
||||
{
|
||||
var name = resolution.RenameTo ?? dto.UniqueName;
|
||||
var inst = BuildInstance(
|
||||
dto, name, templateId, targetSite, areaId, connectionMaps);
|
||||
await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Create", "Instance", "0", name,
|
||||
new { UniqueName = name, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier, RenamedFrom = dto.UniqueName },
|
||||
ct).ConfigureAwait(false);
|
||||
summary.Renamed++;
|
||||
break;
|
||||
}
|
||||
case ResolutionAction.Overwrite:
|
||||
{
|
||||
var existing = await _templateRepo
|
||||
.GetInstanceByUniqueNameAsync(dto.UniqueName, ct).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
// Overwrite chosen but no target row — treat as Add (the
|
||||
// preview's "existing" read may have raced a delete). Write
|
||||
// a fresh instance under the bundle's UniqueName.
|
||||
var added = BuildInstance(
|
||||
dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
|
||||
await _templateRepo.AddInstanceAsync(added, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Create", "Instance", "0", added.UniqueName,
|
||||
new { added.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
|
||||
ct).ConfigureAwait(false);
|
||||
summary.Added++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Rewire scalar FKs + state, then replace child rows.
|
||||
existing.TemplateId = templateId;
|
||||
existing.SiteId = targetSite.Id;
|
||||
existing.AreaId = areaId;
|
||||
existing.State = InstanceState.NotDeployed;
|
||||
await ReplaceInstanceChildrenAsync(
|
||||
existing, dto, dto.SiteIdentifier, connectionMaps, ct)
|
||||
.ConfigureAwait(false);
|
||||
await _templateRepo.UpdateInstanceAsync(existing, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Update", "Instance", existing.Id.ToString(), existing.UniqueName,
|
||||
new { existing.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
|
||||
ct).ConfigureAwait(false);
|
||||
summary.Overwritten++;
|
||||
break;
|
||||
}
|
||||
case ResolutionAction.Add:
|
||||
default:
|
||||
{
|
||||
var inst = BuildInstance(
|
||||
dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
|
||||
await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Create", "Instance", "0", inst.UniqueName,
|
||||
new { inst.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
|
||||
ct).ConfigureAwait(false);
|
||||
summary.Added++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an instance's area by name within the target site, creating the
|
||||
/// area if it doesn't exist. Returns null when <paramref name="areaName"/> is
|
||||
/// null/empty (the instance has no area). Memoised via
|
||||
/// <paramref name="areaIdByKey"/> so repeated references resolve to one row.
|
||||
/// </summary>
|
||||
private async Task<int?> ResolveOrCreateAreaIdAsync(
|
||||
string? areaName,
|
||||
int siteId,
|
||||
Dictionary<(int SiteId, string Name), int> areaIdByKey,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(areaName)) return null;
|
||||
|
||||
var key = (siteId, areaName);
|
||||
if (areaIdByKey.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var existingAreas = await _templateRepo.GetAreasBySiteIdAsync(siteId, ct).ConfigureAwait(false);
|
||||
var match = existingAreas.FirstOrDefault(a => string.Equals(a.Name, areaName, StringComparison.Ordinal));
|
||||
if (match is not null)
|
||||
{
|
||||
areaIdByKey[key] = match.Id;
|
||||
return match.Id;
|
||||
}
|
||||
|
||||
var area = new Area(areaName) { SiteId = siteId };
|
||||
await _templateRepo.AddAreaAsync(area, ct).ConfigureAwait(false);
|
||||
// Flush so the area's surrogate id materialises before it's used as an
|
||||
// instance FK (relational provider). Rides the outer transaction.
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
areaIdByKey[key] = area.Id;
|
||||
return area.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a new <see cref="Instance"/> from a DTO with all four child
|
||||
/// collections populated and every cross-environment FK rewired to the
|
||||
/// target. State is always <see cref="InstanceState.NotDeployed"/>.
|
||||
/// </summary>
|
||||
private static Instance BuildInstance(
|
||||
InstanceDto dto,
|
||||
string uniqueName,
|
||||
int templateId,
|
||||
Site targetSite,
|
||||
int? areaId,
|
||||
ResolvedConnectionMaps connectionMaps)
|
||||
{
|
||||
var inst = new Instance(uniqueName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SiteId = targetSite.Id,
|
||||
AreaId = areaId,
|
||||
State = InstanceState.NotDeployed,
|
||||
};
|
||||
// The connection maps are keyed by the SOURCE site identifier (dto.SiteIdentifier),
|
||||
// NOT the target's — a cross-site MapToExisting redirect resolves the binding
|
||||
// through the source key, then the map already points at the target's id/name.
|
||||
PopulateInstanceChildren(inst, dto, dto.SiteIdentifier, connectionMaps);
|
||||
return inst;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the four child collections on a tracked/new instance from the
|
||||
/// DTO. Connection bindings rewire <c>ConnectionName</c> → the target
|
||||
/// <c>DataConnectionId</c>; native-alarm-source overrides rewrite
|
||||
/// <c>ConnectionNameOverride</c> to the MAPPED target connection name. The
|
||||
/// connection map is keyed by (sourceSiteIdentifier, sourceConnectionName) —
|
||||
/// the instance's own <c>SiteIdentifier</c> is the source-site key.
|
||||
/// </summary>
|
||||
private static void PopulateInstanceChildren(
|
||||
Instance inst,
|
||||
InstanceDto dto,
|
||||
string sourceSiteIdentifier,
|
||||
ResolvedConnectionMaps connectionMaps)
|
||||
{
|
||||
foreach (var o in dto.AttributeOverrides)
|
||||
{
|
||||
inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
|
||||
{
|
||||
OverrideValue = o.OverrideValue,
|
||||
ElementDataType = o.ElementDataType,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.AlarmOverrides)
|
||||
{
|
||||
inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
|
||||
{
|
||||
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride = o.PriorityLevelOverride,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.NativeAlarmSourceOverrides)
|
||||
{
|
||||
// Rewrite the connection-name override to the MAPPED target connection
|
||||
// name (the binding FK is an id, but this override stores a NAME). When
|
||||
// the source name maps to a differently-named target connection we must
|
||||
// carry the target name forward; otherwise keep the original (it already
|
||||
// names a target connection, e.g. an unmapped pass-through).
|
||||
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
|
||||
{
|
||||
ConnectionNameOverride = RewriteConnectionName(
|
||||
o.ConnectionNameOverride, sourceSiteIdentifier, connectionMaps),
|
||||
SourceReferenceOverride = o.SourceReferenceOverride,
|
||||
ConditionFilterOverride = o.ConditionFilterOverride,
|
||||
});
|
||||
}
|
||||
foreach (var b in dto.ConnectionBindings)
|
||||
{
|
||||
// Resolve ConnectionName → target DataConnectionId. A binding whose
|
||||
// connection didn't resolve was a preview blocker; default to 0 here
|
||||
// (the FK constraint / a later deploy gate surfaces it) rather than
|
||||
// throwing, since the binding's attribute may legitimately be unbound.
|
||||
connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
|
||||
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
|
||||
{
|
||||
DataConnectionId = connId,
|
||||
DataSourceReferenceOverride = b.DataSourceReferenceOverride,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces an existing instance's four child collections with the bundle's
|
||||
/// (Overwrite semantics — the bundle is the source of truth). Deletes the
|
||||
/// current rows via the repo, clears the tracked navigations, then re-adds
|
||||
/// from the DTO with FKs rewired. Mirrors the template-overwrite child sync.
|
||||
/// </summary>
|
||||
private async Task ReplaceInstanceChildrenAsync(
|
||||
Instance existing,
|
||||
InstanceDto dto,
|
||||
string sourceSiteIdentifier,
|
||||
ResolvedConnectionMaps connectionMaps,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var o in existing.AttributeOverrides.ToList())
|
||||
{
|
||||
await _templateRepo.DeleteInstanceAttributeOverrideAsync(o.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
existing.AttributeOverrides.Clear();
|
||||
foreach (var o in existing.AlarmOverrides.ToList())
|
||||
{
|
||||
await _templateRepo.DeleteInstanceAlarmOverrideAsync(o.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
existing.AlarmOverrides.Clear();
|
||||
foreach (var o in existing.NativeAlarmSourceOverrides.ToList())
|
||||
{
|
||||
await _templateRepo.DeleteInstanceNativeAlarmSourceOverrideAsync(o.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
existing.NativeAlarmSourceOverrides.Clear();
|
||||
foreach (var b in existing.ConnectionBindings.ToList())
|
||||
{
|
||||
await _templateRepo.DeleteInstanceConnectionBindingAsync(b.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
existing.ConnectionBindings.Clear();
|
||||
|
||||
PopulateInstanceChildren(existing, dto, sourceSiteIdentifier, connectionMaps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a source connection-name reference (a native-alarm-source
|
||||
/// <c>ConnectionNameOverride</c>) to the MAPPED target connection name. The
|
||||
/// lookup is <c>(sourceSite, sourceName) → target Name</c> via the
|
||||
/// target-name map <see cref="ApplyDataConnectionsAsync"/> built — so a
|
||||
/// differently-named MapToExisting redirect (sourceConn "OpcA" mapped onto
|
||||
/// target "OpcUaPrimary") carries through correctly. Returns the original
|
||||
/// name unchanged when it doesn't resolve in the map (an unmapped
|
||||
/// pass-through that already names a target connection — e.g. a connection
|
||||
/// that auto-matched an existing target but wasn't carried in the bundle's
|
||||
/// DataConnections, so it isn't in the map).
|
||||
/// </summary>
|
||||
private static string? RewriteConnectionName(
|
||||
string? sourceName,
|
||||
string sourceSiteIdentifier,
|
||||
ResolvedConnectionMaps connectionMaps)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceName)) return sourceName;
|
||||
return connectionMaps.TargetNameBySourceRef.TryGetValue((sourceSiteIdentifier, sourceName), out var targetName)
|
||||
? targetName
|
||||
: sourceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M8: validates every cross-environment reference the site/instance payload
|
||||
/// depends on, BEFORE any row is staged — so a structurally-unresolvable
|
||||
/// bundle fails with an empty change tracker (preserving the all-or-nothing
|
||||
/// rollback contract on every EF provider, including the in-memory one whose
|
||||
/// intermediate flush can't be undone by <c>ChangeTracker.Clear</c>).
|
||||
/// <para>Checks, mirroring the resolve-or-create logic the apply passes use:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>every non-Skip instance's <c>TemplateName</c> resolves to an in-bundle
|
||||
/// (non-Skip) template or a pre-existing target template;</item>
|
||||
/// <item>every referenced site resolves — carried in the bundle (auto-creatable),
|
||||
/// or mapped/auto-matched to an existing target site;</item>
|
||||
/// <item>every referenced connection resolves — carried in the bundle
|
||||
/// (auto-creatable under its mapped site), or mapped/auto-matched to an existing
|
||||
/// connection in the mapped target site.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<string>> ValidateSiteInstanceReferencesAsync(
|
||||
BundleContentDto content,
|
||||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||||
BundleNameMap nameMap,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (content.Instances.Count == 0 && content.DataConnections.Count == 0 && content.Sites.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var siteMappingByIdentifier = nameMap.Sites
|
||||
.GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var connMappingByRef = nameMap.Connections
|
||||
.GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// Sites carried in the bundle are always resolvable (CreateNew or
|
||||
// MapToExisting both yield a target).
|
||||
var bundleSiteIdentifiers = new HashSet<string>(
|
||||
content.Sites.Select(s => s.SiteIdentifier), StringComparer.Ordinal);
|
||||
|
||||
// Resolve a source site identifier → target Site (memoised). Null = the
|
||||
// source site has no resolvable target AND isn't carried in the bundle.
|
||||
var targetSiteCache = new Dictionary<string, Site?>(StringComparer.Ordinal);
|
||||
async Task<Site?> ResolveTargetSiteAsync(string identifier)
|
||||
{
|
||||
if (targetSiteCache.TryGetValue(identifier, out var cached)) return cached;
|
||||
siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
|
||||
var lookupId = mapping?.Action == MappingAction.MapToExisting
|
||||
&& mapping.TargetSiteIdentifier is { Length: > 0 } t
|
||||
? t
|
||||
: identifier;
|
||||
var site = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
|
||||
targetSiteCache[identifier] = site;
|
||||
return site;
|
||||
}
|
||||
|
||||
// ---- Site references (instances + connections + carried sites) ----
|
||||
foreach (var identifier in EnumerateReferencedSiteIdentifiers(content)
|
||||
.Concat(content.Sites.Select(s => s.SiteIdentifier))
|
||||
.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (bundleSiteIdentifiers.Contains(identifier)) continue; // carried → creatable
|
||||
var target = await ResolveTargetSiteAsync(identifier).ConfigureAwait(false);
|
||||
if (target is null)
|
||||
{
|
||||
errors.Add(
|
||||
$"Site '{identifier}' is referenced by the bundle but is neither carried in the "
|
||||
+ "bundle nor resolvable to an existing target site.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Connection references (bindings + native-alarm overrides + carried) ----
|
||||
var bundleConnectionRefs = new HashSet<(string Site, string Name)>(
|
||||
content.DataConnections.Select(dc => (dc.SiteIdentifier, dc.Name)));
|
||||
|
||||
var referencedConnections = new HashSet<(string Site, string Name)>(bundleConnectionRefs);
|
||||
foreach (var inst in content.Instances)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
foreach (var b in inst.ConnectionBindings)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(b.ConnectionName))
|
||||
referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
|
||||
}
|
||||
foreach (var n in inst.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
|
||||
referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (site, name) in referencedConnections)
|
||||
{
|
||||
if (bundleConnectionRefs.Contains((site, name))) continue; // carried → creatable
|
||||
var targetSite = await ResolveTargetSiteAsync(site).ConfigureAwait(false);
|
||||
if (targetSite is null) continue; // already flagged above as a site error
|
||||
connMappingByRef.TryGetValue((site, name), out var mapping);
|
||||
var targetName = mapping?.Action == MappingAction.MapToExisting
|
||||
&& mapping.TargetConnectionName is { Length: > 0 } tn
|
||||
? tn
|
||||
: name;
|
||||
var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(targetSite.Id, ct).ConfigureAwait(false);
|
||||
if (!conns.Any(c => string.Equals(c.Name, targetName, StringComparison.Ordinal)))
|
||||
{
|
||||
errors.Add(
|
||||
$"Connection '{site}/{name}' is referenced by the bundle but is neither carried in "
|
||||
+ "the bundle nor resolvable to an existing connection in the mapped target site.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Instance template references ----
|
||||
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in content.Templates)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
bundleTemplateNames.Add(t.Name);
|
||||
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
|
||||
bundleTemplateNames.Add(resolution.RenameTo);
|
||||
}
|
||||
var targetTemplateNames = new HashSet<string>(
|
||||
(await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)).Select(t => t.Name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var inst in content.Instances)
|
||||
{
|
||||
var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
|
||||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||||
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||||
if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
|
||||
if (targetTemplateNames.Contains(inst.TemplateName)) continue;
|
||||
errors.Add(
|
||||
$"Instance '{inst.UniqueName}' references template '{inst.TemplateName}' which is "
|
||||
+ "present in neither the bundle nor the target.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-tier semantic validation run before any rows are flushed:
|
||||
/// <list type="number">
|
||||
|
||||
Reference in New Issue
Block a user