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
127 lines
5.5 KiB
C#
127 lines
5.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #66: <see cref="TemplateEngineRepository.GetTemplateAnalysisClosureAsync"/> reads only a
|
|
/// template's inheritance chain + composition closure — one projection query per closure depth —
|
|
/// instead of the whole library that <see cref="TemplateEngineRepository.GetAllTemplatesForAnalysisAsync"/>
|
|
/// materialises. Asserted against real SQL on SQLite with the reader commands recorded.
|
|
/// </summary>
|
|
public class TemplateAnalysisClosureRepositoryTests : IDisposable
|
|
{
|
|
private const int UnrelatedTemplates = 40;
|
|
private const string BigBody = "/* script body that must never be read by analysis */";
|
|
|
|
private readonly SqlReaderCommandRecorder _recorder = new();
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly TemplateEngineRepository _repository;
|
|
|
|
/// <summary>Initializes the test class with an in-memory context and repository.</summary>
|
|
public TemplateAnalysisClosureRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext(_recorder);
|
|
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
private async Task<Template> AddAsync(string name, int? parentId = null)
|
|
{
|
|
var t = new Template(name) { ParentTemplateId = parentId };
|
|
t.Attributes.Add(new TemplateAttribute($"{name}Attr1"));
|
|
t.Attributes.Add(new TemplateAttribute($"{name}Attr2"));
|
|
t.Scripts.Add(new TemplateScript($"{name}Script", BigBody));
|
|
_context.Templates.Add(t);
|
|
await _context.SaveChangesAsync();
|
|
return t;
|
|
}
|
|
|
|
private async Task ComposeAsync(Template owner, Template module, string slot)
|
|
{
|
|
_context.Set<TemplateComposition>().Add(
|
|
new TemplateComposition(slot) { TemplateId = owner.Id, ComposedTemplateId = module.Id });
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
private sealed record Seeded(int Grand, int Parent, int Child, int ModA, int ModB, int ModBBase, int Inner);
|
|
|
|
private async Task<Seeded> SeedAsync()
|
|
{
|
|
var grand = await AddAsync("Grand");
|
|
var parent = await AddAsync("Parent", grand.Id);
|
|
var child = await AddAsync("Child", parent.Id);
|
|
var modBBase = await AddAsync("ModBBase");
|
|
var modB = await AddAsync("ModB", modBBase.Id);
|
|
var modA = await AddAsync("ModA");
|
|
var inner = await AddAsync("Inner");
|
|
await ComposeAsync(parent, modA, "m1");
|
|
await ComposeAsync(child, modB, "m2");
|
|
await ComposeAsync(modA, inner, "inner");
|
|
|
|
for (var i = 0; i < UnrelatedTemplates; i++)
|
|
await AddAsync($"Unrelated{i}");
|
|
|
|
_context.ChangeTracker.Clear();
|
|
_recorder.Reset();
|
|
return new Seeded(grand.Id, parent.Id, child.Id, modA.Id, modB.Id, modBBase.Id, inner.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The closure is exactly the chain + composition closure, loaded untracked without script
|
|
/// bodies, in one query per depth — against 47 templates for the whole-graph load.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_ReadsOnlyChainAndCompositionClosure_OneQueryPerDepth()
|
|
{
|
|
var ids = await SeedAsync();
|
|
|
|
var whole = await _repository.GetAllTemplatesForAnalysisAsync();
|
|
var wholeQueries = _recorder.Commands.Count;
|
|
_recorder.Reset();
|
|
|
|
var closure = await _repository.GetTemplateAnalysisClosureAsync(ids.Child);
|
|
var closureQueries = _recorder.Commands.Count;
|
|
|
|
// Entity count: 6 templates / 12 attributes instead of 47 / 94.
|
|
Assert.Equal(
|
|
new[] { ids.Grand, ids.Parent, ids.Child, ids.ModA, ids.ModB, ids.Inner }.OrderBy(i => i),
|
|
closure.Select(t => t.Id).OrderBy(i => i));
|
|
Assert.Equal(7 + UnrelatedTemplates, whole.Count);
|
|
Assert.Equal(12, closure.Sum(t => t.Attributes.Count));
|
|
Assert.Equal(2 * (7 + UnrelatedTemplates), whole.Sum(t => t.Attributes.Count));
|
|
|
|
// Query count: one projection query per depth (Child; Parent+ModB; Grand+ModA; Inner).
|
|
Assert.Equal(4 * wholeQueries, closureQueries);
|
|
|
|
// Same analysis shape as the whole-graph load: untracked, no script bodies, edges present.
|
|
Assert.Empty(_context.ChangeTracker.Entries());
|
|
Assert.All(closure.SelectMany(t => t.Scripts), s => Assert.Equal(string.Empty, s.Code));
|
|
Assert.False(_recorder.AnySelectReads("\"Code\""));
|
|
Assert.Single(closure.Single(t => t.Id == ids.Child).Compositions);
|
|
|
|
// Positive control: the body column IS visible to the recorder when a load reads it.
|
|
_recorder.Reset();
|
|
await _repository.GetAllTemplatesAsync();
|
|
Assert.True(_recorder.AnySelectReads("\"Code\""));
|
|
}
|
|
|
|
/// <summary>An unknown id loads nothing beyond the single probe query.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_UnknownTemplate_IsEmpty()
|
|
{
|
|
await SeedAsync();
|
|
Assert.Empty(await _repository.GetTemplateAnalysisClosureAsync(987654));
|
|
}
|
|
}
|