792f16c2d5
Attribute/alarm/script add (collision check) and update (parent-lock lookup) used GetAllTemplatesForAnalysisAsync, materialising every template, attribute, alarm and script in the library per edit. They now call the new ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked, body-free analysis projection, restricted to the template's inheritance chain plus the composition closure of every chain member - exactly what TemplateResolver and CollisionDetector walk (a composed module's own parent chain is never climbed, so it is not loaded). Adds use the edited template's closure, updates the parent's. The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth, requests each id once, terminates on cycles and skips dangling edges. Evidence: - TemplateAnalysisClosureTests: for every template of a graph with chains, nested + diamond compositions, a derived module, cycles, dangling edges and colliding unrelated templates, ResolveAllMembers / DetectCollisions / FindMemberByCanonicalName answer identically from the closure and the whole graph (mutation-checked). - TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates / 12 attributes loaded instead of 47 / 94, in 4 queries (one per depth), untracked, script bodies never selected. - TemplateServiceTests: two new tests with the whole-graph load wired to throw (fail on the old service); every existing add/update test now runs against the closure via AnalysisClosureMock. ReconcileDescendantsAsync still loads the tracked full graph; not in scope. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
954 lines
38 KiB
C#
954 lines
38 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
public class TemplateEngineRepository : ITemplateEngineRepository
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly ITemplateGraphWatermark _watermark;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the TemplateEngineRepository class.
|
|
/// </summary>
|
|
/// <param name="context">The database context used to access template and instance data.</param>
|
|
/// <param name="watermark">
|
|
/// Process-wide template/instance version watermark. This repository is the
|
|
/// single unit-of-work through which EVERY template-graph mutation commits —
|
|
/// <c>TemplateService</c>, the <c>ManagementActor</c> native-alarm-source
|
|
/// handlers, and the Transport bundle importer all funnel through the same
|
|
/// <see cref="SaveChangesAsync"/> — so bumping here (rather than in each
|
|
/// caller) is the only placement that cannot be bypassed.
|
|
/// </param>
|
|
public TemplateEngineRepository(ScadaBridgeDbContext context, ITemplateGraphWatermark watermark)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
|
|
}
|
|
|
|
// Template
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetTemplateByIdAsync(id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
|
IEnumerable<string> names, CancellationToken cancellationToken = default)
|
|
{
|
|
// Bulk lookup replaces the per-name N+1 in
|
|
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
|
|
// names before the query so EF emits a clean, deduplicated IN clause.
|
|
if (names is null) return Array.Empty<Template>();
|
|
var distinct = names
|
|
.Where(n => !string.IsNullOrEmpty(n))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (distinct.Length == 0) return Array.Empty<Template>();
|
|
|
|
return await _context.Templates
|
|
.Where(t => distinct.Contains(t.Name))
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateStub>> GetTemplateStubsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Templates
|
|
.AsNoTracking()
|
|
.OrderBy(t => t.Name)
|
|
.Select(t => new TemplateStub(t.Id, t.Name, t.ParentTemplateId, t.FolderId, t.IsDerived, t.OwnerCompositionId))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// Two deliberate departures from <see cref="GetAllTemplatesAsync"/>:
|
|
// <list type="number">
|
|
// <item>
|
|
// <c>AsNoTracking</c> — the analysis walks (cycle detection, collision
|
|
// detection, canonical-name resolution) never write through the loaded
|
|
// graph, so paying for a change-tracker snapshot of every attribute,
|
|
// alarm, script and composition in the database is pure waste.
|
|
// </item>
|
|
// <item>
|
|
// <c>TemplateScript.Code</c> is projected away (left empty). Script
|
|
// bodies are by far the largest column in the graph and NONE of the
|
|
// analysis consumers read them — <c>CycleDetector</c> reads only ids and
|
|
// edges, <c>CollisionDetector</c> and <c>TemplateResolver</c> read only
|
|
// member names and lock flags.
|
|
// </item>
|
|
// </list>
|
|
// Callers that DO need bodies or tracked entities — the inheritance
|
|
// reconciler and the flattener — must keep using
|
|
// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
|
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await AnalysisProjection(_context.Templates.AsNoTracking())
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// One projection query per closure depth (TemplateAnalysisClosure batches each frontier),
|
|
// each restricted to the frontier's ids — so a member edit reads its own chain and modules
|
|
// instead of every template, attribute, alarm and script in the library.
|
|
public Task<IReadOnlyList<Template>> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return TemplateAnalysisClosure.CollectAsync(
|
|
templateId,
|
|
async (ids, ct) =>
|
|
{
|
|
var idList = ids.ToList();
|
|
return await AnalysisProjection(_context.Templates.AsNoTracking().Where(t => idList.Contains(t.Id)))
|
|
.ToListAsync(ct);
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
// The analysis projection shared by GetAllTemplatesForAnalysisAsync and
|
|
// GetTemplateAnalysisClosureAsync (see the note on the former for why it is untracked and
|
|
// drops TemplateScript.Code).
|
|
private static IQueryable<Template> AnalysisProjection(IQueryable<Template> source)
|
|
{
|
|
return source
|
|
.Select(t => new Template(t.Name)
|
|
{
|
|
Id = t.Id,
|
|
Description = t.Description,
|
|
ParentTemplateId = t.ParentTemplateId,
|
|
FolderId = t.FolderId,
|
|
IsDerived = t.IsDerived,
|
|
OwnerCompositionId = t.OwnerCompositionId,
|
|
Attributes = t.Attributes.ToList(),
|
|
Alarms = t.Alarms.ToList(),
|
|
NativeAlarmSources = t.NativeAlarmSources.ToList(),
|
|
Compositions = t.Compositions.ToList(),
|
|
Scripts = t.Scripts
|
|
.Select(s => new TemplateScript(s.Name, string.Empty)
|
|
{
|
|
Id = s.Id,
|
|
TemplateId = s.TemplateId,
|
|
IsLocked = s.IsLocked,
|
|
TriggerType = s.TriggerType,
|
|
TriggerConfiguration = s.TriggerConfiguration,
|
|
ParameterDefinitions = s.ParameterDefinitions,
|
|
ReturnDefinition = s.ReturnDefinition,
|
|
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
|
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
|
IsInherited = s.IsInherited,
|
|
LockedInDerived = s.LockedInDerived
|
|
})
|
|
.ToList()
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateSummary>> GetTemplateSummariesAsync(
|
|
int skip,
|
|
int? take,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = _context.Templates
|
|
.AsNoTracking()
|
|
.OrderBy(t => t.Id)
|
|
.Skip(Math.Max(0, skip));
|
|
|
|
if (take is > 0)
|
|
query = query.Take(Math.Min(take.Value, TemplateSummary.MaxPageSize));
|
|
|
|
return await query
|
|
.Select(t => new TemplateSummary(
|
|
t.Id,
|
|
t.Name,
|
|
t.Description,
|
|
t.ParentTemplateId,
|
|
t.FolderId,
|
|
t.IsDerived,
|
|
t.OwnerCompositionId,
|
|
t.Attributes.Count,
|
|
t.Alarms.Count,
|
|
t.Scripts.Count,
|
|
t.Compositions.Count,
|
|
t.NativeAlarmSources.Count))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Templates.AddAsync(template, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Templates.Update(template);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
|
|
if (template != null)
|
|
{
|
|
_context.Templates.Remove(template);
|
|
}
|
|
}
|
|
|
|
// TemplateAttribute
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAttributes
|
|
.Where(a => a.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateAttributes.Update(attribute);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
|
if (attribute != null)
|
|
{
|
|
_context.TemplateAttributes.Remove(attribute);
|
|
}
|
|
}
|
|
|
|
// TemplateAlarm
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAlarms
|
|
.Where(a => a.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateAlarms.Update(alarm);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
|
if (alarm != null)
|
|
{
|
|
_context.TemplateAlarms.Remove(alarm);
|
|
}
|
|
}
|
|
|
|
// TemplateNativeAlarmSource
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateNativeAlarmSource?> GetTemplateNativeAlarmSourceByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateNativeAlarmSource>> GetNativeAlarmSourcesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateNativeAlarmSources
|
|
.Where(s => s.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateNativeAlarmSources.AddAsync(source, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateNativeAlarmSources.Update(source);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateNativeAlarmSourceAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var source = await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
|
|
if (source != null)
|
|
{
|
|
_context.TemplateNativeAlarmSources.Remove(source);
|
|
}
|
|
}
|
|
|
|
// TemplateScript
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateScripts
|
|
.Where(s => s.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateScripts.AddAsync(script, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateScripts.Update(script);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
if (script != null)
|
|
{
|
|
_context.TemplateScripts.Remove(script);
|
|
}
|
|
}
|
|
|
|
// TemplateComposition
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateCompositions
|
|
.Where(c => c.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateCompositions.Update(composition);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
|
if (composition != null)
|
|
{
|
|
_context.TemplateCompositions.Remove(composition);
|
|
}
|
|
}
|
|
|
|
// Instance
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Where(i => i.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Where(i => i.SiteId == siteId)
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(skip);
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(take, 1);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(take, 50);
|
|
return await _context.Instances.AsNoTracking()
|
|
.Where(i => i.SiteId == siteId && i.AreaId == areaId)
|
|
.OrderBy(i => i.UniqueName).ThenBy(i => i.Id)
|
|
.Skip(skip).Take(take).ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetInstancesByUniqueNamesAsync(
|
|
IEnumerable<string> uniqueNames, CancellationToken cancellationToken = default)
|
|
{
|
|
// Bulk variant for the bundle importer's preview + instance pass, which used to
|
|
// call GetInstanceByUniqueNameAsync once per bundled instance (review 2026-10-21
|
|
// 04/P4). De-duplicate before the query so EF emits a clean IN list.
|
|
if (uniqueNames is null) return Array.Empty<Instance>();
|
|
var distinct = uniqueNames
|
|
.Where(n => !string.IsNullOrEmpty(n))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (distinct.Length == 0) return Array.Empty<Instance>();
|
|
|
|
return await _context.Instances
|
|
.Where(i => distinct.Contains(i.UniqueName))
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Instances.AddAsync(instance, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Instances.Update(instance);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
|
|
if (instance != null)
|
|
{
|
|
_context.Instances.Remove(instance);
|
|
}
|
|
}
|
|
|
|
// InstanceAttributeOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAttributeOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceAttributeOverrides.Update(attributeOverride);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (attributeOverride != null)
|
|
{
|
|
_context.InstanceAttributeOverrides.Remove(attributeOverride);
|
|
}
|
|
}
|
|
|
|
// InstanceAlarmOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAlarmOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAlarmOverrides
|
|
.FirstOrDefaultAsync(
|
|
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceAlarmOverrides.Update(alarmOverride);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (alarmOverride != null)
|
|
{
|
|
_context.InstanceAlarmOverrides.Remove(alarmOverride);
|
|
}
|
|
}
|
|
|
|
// InstanceNativeAlarmSourceOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceNativeAlarmSourceOverride>> GetNativeAlarmSourceOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceNativeAlarmSourceOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<InstanceNativeAlarmSourceOverride?> GetNativeAlarmSourceOverrideAsync(int instanceId, string sourceCanonicalName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceNativeAlarmSourceOverrides
|
|
.FirstOrDefaultAsync(o => o.InstanceId == instanceId && o.SourceCanonicalName == sourceCanonicalName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceNativeAlarmSourceOverrides.AddAsync(ovr, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceNativeAlarmSourceOverrides.Update(ovr);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceNativeAlarmSourceOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var ovr = await _context.InstanceNativeAlarmSourceOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (ovr != null)
|
|
{
|
|
_context.InstanceNativeAlarmSourceOverrides.Remove(ovr);
|
|
}
|
|
}
|
|
|
|
// InstanceConnectionBinding
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceConnectionBindings
|
|
.Where(b => b.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceConnectionBindings.Update(binding);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
|
|
if (binding != null)
|
|
{
|
|
_context.InstanceConnectionBindings.Remove(binding);
|
|
}
|
|
}
|
|
|
|
// Area
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Areas
|
|
.Include(a => a.Children)
|
|
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Areas
|
|
.Where(a => a.SiteId == siteId)
|
|
.Include(a => a.Children)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Areas.AddAsync(area, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Areas.Update(area);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
|
|
if (area != null)
|
|
{
|
|
_context.Areas.Remove(area);
|
|
}
|
|
}
|
|
|
|
// SharedScript
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts
|
|
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.SharedScripts.Update(sharedScript);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
if (sharedScript != null)
|
|
{
|
|
_context.SharedScripts.Remove(sharedScript);
|
|
}
|
|
}
|
|
|
|
// TemplateFolder
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders
|
|
.OrderBy(f => f.SortOrder)
|
|
.ThenBy(f => f.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateFolders.Update(folder);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var folder = await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
|
if (folder != null)
|
|
{
|
|
_context.TemplateFolders.Remove(folder);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// Inspects the change tracker BEFORE committing (afterwards every entry is
|
|
// <see cref="EntityState.Unchanged"/> and the attribution is lost) and bumps
|
|
// the <see cref="ITemplateGraphWatermark"/> for each affected template /
|
|
// instance. The bump is applied only AFTER a successful commit, so a failed
|
|
// save does not invalidate caches for a change that never landed.
|
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var pending = CollectWatermarkBumps();
|
|
var written = await _context.SaveChangesAsync(cancellationToken);
|
|
ApplyWatermarkBumps(pending);
|
|
return written;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot of the watermark bumps implied by the current change-tracker
|
|
/// contents. Captured pre-commit, applied post-commit.
|
|
/// </summary>
|
|
private readonly record struct WatermarkBumps(
|
|
HashSet<int> Templates,
|
|
HashSet<int> StructuralTemplates,
|
|
HashSet<int> Instances,
|
|
bool Unattributed);
|
|
|
|
/// <summary>
|
|
/// Walks the change tracker and maps every added/modified/deleted
|
|
/// template-graph or instance-graph entry back to the template (or instance)
|
|
/// whose flattened output it can affect.
|
|
///
|
|
/// <para>
|
|
/// A change is treated as STRUCTURAL — invalidating cached chain membership,
|
|
/// not just chain contents — when it adds or removes a <see cref="Template"/>
|
|
/// row, changes a <see cref="Template.ParentTemplateId"/>, or touches a
|
|
/// <see cref="TemplateComposition"/> row. Those are exactly the edges the
|
|
/// flattener walks to build a chain.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// An entry whose owning id cannot be resolved (a child row deleted before
|
|
/// its FK was materialised, or an entity type added later that this switch
|
|
/// does not know) sets <c>Unattributed</c>, which conservatively invalidates
|
|
/// every cached chain rather than silently letting a stale entry survive.
|
|
/// </para>
|
|
/// </summary>
|
|
private WatermarkBumps CollectWatermarkBumps()
|
|
{
|
|
var templates = new HashSet<int>();
|
|
var structural = new HashSet<int>();
|
|
var instances = new HashSet<int>();
|
|
var unattributed = false;
|
|
|
|
foreach (var entry in _context.ChangeTracker.Entries())
|
|
{
|
|
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
|
|
continue;
|
|
|
|
switch (entry.Entity)
|
|
{
|
|
case Template t:
|
|
templates.Add(t.Id);
|
|
// Add/Delete changes chain membership; a re-parent changes it too.
|
|
if (entry.State != EntityState.Modified
|
|
|| entry.Property(nameof(Template.ParentTemplateId)).IsModified)
|
|
{
|
|
structural.Add(t.Id);
|
|
}
|
|
break;
|
|
case TemplateAttribute a:
|
|
templates.Add(a.TemplateId);
|
|
break;
|
|
case TemplateAlarm al:
|
|
templates.Add(al.TemplateId);
|
|
break;
|
|
case TemplateScript s:
|
|
templates.Add(s.TemplateId);
|
|
break;
|
|
case TemplateNativeAlarmSource ns:
|
|
templates.Add(ns.TemplateId);
|
|
break;
|
|
case TemplateComposition c:
|
|
templates.Add(c.TemplateId);
|
|
structural.Add(c.TemplateId);
|
|
break;
|
|
case Instance i:
|
|
instances.Add(i.Id);
|
|
break;
|
|
case InstanceAttributeOverride ao:
|
|
instances.Add(ao.InstanceId);
|
|
break;
|
|
case InstanceAlarmOverride alo:
|
|
instances.Add(alo.InstanceId);
|
|
break;
|
|
case InstanceNativeAlarmSourceOverride nso:
|
|
instances.Add(nso.InstanceId);
|
|
break;
|
|
case InstanceConnectionBinding cb:
|
|
instances.Add(cb.InstanceId);
|
|
break;
|
|
case SharedScript:
|
|
// Shared scripts are a session-global input to validation, not
|
|
// owned by any one template — invalidate everything.
|
|
unattributed = true;
|
|
break;
|
|
default:
|
|
// Anything this switch does not recognise is invalidated
|
|
// conservatively, as the doc comment above promises. The
|
|
// previous "areas, folders and anything else do not feed the
|
|
// flattener" no-op was wrong in at least one shipped case:
|
|
// DataConnection rows land here, and their Protocol /
|
|
// Primary+Backup configuration / FailoverRetryCount ARE hash
|
|
// inputs (FlatteningService packages them as ConnectionConfig,
|
|
// RevisionHashService hashes them as HashableConnection). A
|
|
// silent no-op on an unrecognised entity is the one failure
|
|
// mode this watermark must not have: it produces STALE work,
|
|
// whereas an over-broad bump only ever produces extra work.
|
|
unattributed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return new WatermarkBumps(templates, structural, instances, unattributed);
|
|
}
|
|
|
|
/// <summary>Applies a previously collected <see cref="WatermarkBumps"/> snapshot.</summary>
|
|
private void ApplyWatermarkBumps(WatermarkBumps bumps)
|
|
{
|
|
if (bumps.Unattributed)
|
|
_watermark.BumpAll();
|
|
|
|
foreach (var templateId in bumps.Templates)
|
|
_watermark.BumpTemplate(templateId, bumps.StructuralTemplates.Contains(templateId));
|
|
|
|
foreach (var instanceId in bumps.Instances)
|
|
_watermark.BumpInstance(instanceId);
|
|
}
|
|
}
|