feat(templates): phase 4+5 — inherit/override resolution + lock enforcement
FlatteningService now treats IsInherited rows as placeholders: when a
derived template carries an inherited attribute or script, the live base
value resolves through the ParentTemplateId chain instead of the
(possibly stale) copy. An IsInherited=false row is a real override and
wins as before.
ValidateLockedInDerived runs once per chain (main + composed) and returns
a flatten-time failure if a derived template overrides a base row that
the base marked LockedInDerived.
TemplateService.Update{Attribute,Script}Async reject mid-flight when a
derived target tries to override a LockedInDerived base member, and now
persist IsInherited/LockedInDerived from the proposed payload so the UI
can flip override state or set base-locks via the same endpoints.
This commit is contained in:
@@ -54,6 +54,17 @@ public class FlatteningService
|
||||
|
||||
try
|
||||
{
|
||||
// Step 0: Validate LockedInDerived isn't violated by any chain.
|
||||
var lockError = ValidateLockedInDerived(templateChain);
|
||||
if (lockError != null)
|
||||
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||
foreach (var composedChain in composedTemplateChains.Values)
|
||||
{
|
||||
lockError = ValidateLockedInDerived(composedChain);
|
||||
if (lockError != null)
|
||||
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||
}
|
||||
|
||||
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
||||
var attributes = ResolveInheritedAttributes(templateChain);
|
||||
|
||||
@@ -124,7 +135,10 @@ public class FlatteningService
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
|
||||
// Walk from base (last) to most-derived (first) so derived values win
|
||||
// Walk from base (last) to most-derived (first) so derived values win.
|
||||
// IsInherited rows on a derived template are placeholders that should
|
||||
// not shadow the live base value; they only contribute a row when the
|
||||
// base lacks one.
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
@@ -132,9 +146,13 @@ public class FlatteningService
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
// If a parent defined this attribute as locked, derived cannot change the value
|
||||
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
if (result.TryGetValue(attr.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
if (attr.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
result[attr.Name] = new ResolvedAttribute
|
||||
{
|
||||
@@ -152,6 +170,42 @@ public class FlatteningService
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
||||
/// attribute/script marked LockedInDerived that a downstream derived
|
||||
/// template overrides (IsInherited=false). Returns null on success or an
|
||||
/// error message describing the first offending entries.
|
||||
/// </summary>
|
||||
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
if (attr.LockedInDerived)
|
||||
attrLocks[attr.Name] = template;
|
||||
else if (!attr.IsInherited && attrLocks.TryGetValue(attr.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (script.LockedInDerived)
|
||||
scriptLocks[script.Name] = template;
|
||||
else if (!script.IsInherited && scriptLocks.TryGetValue(script.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Script '{script.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
private static void ResolveComposedAttributes(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
@@ -343,8 +397,13 @@ public class FlatteningService
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
if (result.TryGetValue(script.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
if (script.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
result[script.Name] = new ResolvedScript
|
||||
{
|
||||
|
||||
@@ -264,6 +264,16 @@ public class TemplateService
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateAttribute>.Failure(
|
||||
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
|
||||
// Derived templates may not override fields the base marked LockedInDerived.
|
||||
if (template.IsDerived)
|
||||
{
|
||||
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
|
||||
if (baseAttr != null && baseAttr.LockedInDerived)
|
||||
return Result<TemplateAttribute>.Failure(
|
||||
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate lock change rules
|
||||
@@ -282,6 +292,10 @@ public class TemplateService
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
existing.DataType = proposed.DataType;
|
||||
existing.DataSourceReference = proposed.DataSourceReference;
|
||||
if (template?.IsDerived == true)
|
||||
existing.IsInherited = proposed.IsInherited;
|
||||
else
|
||||
existing.LockedInDerived = proposed.LockedInDerived;
|
||||
|
||||
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
||||
@@ -493,6 +507,16 @@ public class TemplateService
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateScript>.Failure(
|
||||
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
|
||||
// Derived templates may not override scripts the base marked LockedInDerived.
|
||||
if (template.IsDerived)
|
||||
{
|
||||
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
|
||||
if (baseScript != null && baseScript.LockedInDerived)
|
||||
return Result<TemplateScript>.Failure(
|
||||
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate fixed fields
|
||||
@@ -508,6 +532,10 @@ public class TemplateService
|
||||
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
||||
existing.ReturnDefinition = proposed.ReturnDefinition;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
if (template?.IsDerived == true)
|
||||
existing.IsInherited = proposed.IsInherited;
|
||||
else
|
||||
existing.LockedInDerived = proposed.LockedInDerived;
|
||||
// Name is NOT updated (fixed)
|
||||
|
||||
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
||||
|
||||
@@ -262,4 +262,112 @@ public class FlatteningServiceTests
|
||||
Assert.Equal(5, result.Value.SiteId);
|
||||
Assert.Equal(3, result.Value.AreaId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InheritedAttributeOnDerived_BaseValueWins()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "STALE",
|
||||
IsInherited = true
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
|
||||
Assert.Equal("100.0", setPoint.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "150.0",
|
||||
IsInherited = false
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
|
||||
Assert.Equal("150.0", setPoint.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_LockedInDerivedOverride_Fails()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "100.0",
|
||||
LockedInDerived = true
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
Value = "150.0",
|
||||
IsInherited = false
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("LockedInDerived", result.Error);
|
||||
Assert.Contains("SetPoint", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InheritedScriptOnDerived_BaseCodeWins()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;"));
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Scripts.Add(new TemplateScript("Sample", "stale code") { IsInherited = true });
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
|
||||
Assert.Equal("return base;", script.Code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,70 @@ public class TemplateServiceTests
|
||||
Assert.Contains("derived template", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived()
|
||||
{
|
||||
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
||||
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, LockedInDerived = true });
|
||||
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||
|
||||
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived()
|
||||
{
|
||||
var existing = new TemplateScript("Sample", "stale") { Id = 200, TemplateId = 77, IsInherited = true };
|
||||
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return 1;") { Id = 20, TemplateId = 2, LockedInDerived = true });
|
||||
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(200, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||
|
||||
var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
|
||||
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
|
||||
{
|
||||
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
||||
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2 });
|
||||
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||
|
||||
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value.IsInherited);
|
||||
Assert.Equal("99", result.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user