perf(templates): member add/update loads only the chain + composition closure (ScadaBridge#66)

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
This commit is contained in:
Joseph Doherty
2026-11-09 12:52:29 -05:00
parent d36825633b
commit 792f16c2d5
9 changed files with 581 additions and 10 deletions
@@ -0,0 +1,40 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
/// <summary>
/// Gitea #66: member add/update now asks the repository for a template's analysis closure
/// rather than the whole analysis graph. Test classes keep seeding the whole graph through
/// <c>GetAllTemplatesForAnalysisAsync</c>; this wires <c>GetTemplateAnalysisClosureAsync</c>
/// to serve the real closure walk over that seeded graph, so every existing scenario runs the
/// service against the reduced set — the same set the production repository returns.
/// </summary>
internal static class AnalysisClosureMock
{
/// <summary>Wires the closure method to filter whatever the full-graph setup returns.</summary>
/// <param name="repoMock">The repository mock.</param>
/// <param name="closureRequests">Optional sink recording each requested closure root.</param>
public static void ServeClosureFromFullGraph(
this Mock<ITemplateEngineRepository> repoMock,
List<int>? closureRequests = null)
{
repoMock
.Setup(r => r.GetTemplateAnalysisClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((int templateId, CancellationToken ct) =>
{
closureRequests?.Add(templateId);
return TemplateAnalysisClosure.CollectAsync(
templateId,
async (ids, token) =>
{
var all = await repoMock.Object.GetAllTemplatesForAnalysisAsync(token)
?? Array.Empty<Template>();
return all.Where(t => ids.Contains(t.Id)).ToList();
},
ct);
});
}
}