From 2a5f3b54fcf3aa67f9e5c5d71b6d6d9ddaf1f645 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 Mar 2026 10:59:25 +0100 Subject: [PATCH 1/2] Update README for staged submodel composition and remove extra spec doc --- .../AasBuilderTests.cs | 90 ++++++++++++++ FluentAAS/FluentAAS.Builder/AasBuilder.cs | 112 +++++++++++++++++- FluentAAS/FluentAAS.Builder/IAasBuilder.cs | 27 ++++- .../SubModel/SubmodelFragmentBuilder.cs | 85 +++++++++++++ README.md | 46 ++++++- 5 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 FluentAAS/FluentAAS.Builder/SubModel/SubmodelFragmentBuilder.cs diff --git a/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs b/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs index 8b62e81..f446c91 100644 --- a/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs +++ b/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs @@ -151,6 +151,96 @@ public void AddExistingSubmodel_WithValidSubmodel_ShouldAddToEnvironmentAndRetur environment.Submodels.Single().ShouldBeSameAs(submodel); } + [Fact] + public void AddSubmodel_WithValidSubmodel_ShouldAddToEnvironmentAndReturnBuilder() + { + // Arrange + var builder = CreateSut(); + var submodel = new Submodel( + id: _fixture.Create()!, + idShort: _fixture.Create()! + ); + + // Act + var returned = builder.AddSubmodel(submodel); + + // Assert + returned.ShouldBeSameAs(builder); + + var environment = builder.Build(); + environment.Submodels.ShouldContain(submodel); + } + + [Fact] + public void AddSubmodel_WithInvalidIdShort_ShouldThrowArgumentException() + { + // Arrange + var builder = CreateSut(); + var submodel = new Submodel(id: _fixture.Create()!) + { + IdShort = " " + }; + + // Act + var act = () => builder.AddSubmodel(submodel); + + // Assert + var ex = Should.Throw(act); + ex.ParamName.ShouldBe("submodel"); + } + + [Fact] + public void Build_WithDuplicateSubmodelIds_ShouldThrowInvalidOperationException() + { + // Arrange + var builder = CreateSut(); + var duplicateId = _fixture.Create(); + + builder.AddSubmodel(new Submodel(id: duplicateId, idShort: "submodel-a")); + builder.AddSubmodel(new Submodel(id: duplicateId, idShort: "submodel-b")); + + // Act + var act = () => builder.Build(); + + // Assert + var ex = Should.Throw(act); + ex.Message.ShouldContain("Duplicate submodel id(s) detected at build-time"); + } + + [Fact] + public void AddSubmodelFragment_ShouldApplyContributionAtBuildTime() + { + // Arrange + var builder = CreateSut(); + var submodelId = _fixture.Create(); + builder.AddSubmodel(new Submodel(id: submodelId, idShort: "composed-submodel")); + + // Act + builder.AddSubmodelFragment(submodelId, fragment => fragment.AddProperty("temperature", "21.5")); + var env = builder.Build(); + + // Assert + var submodel = env.Submodels.ShouldHaveSingleItem().ShouldBeOfType(); + submodel.SubmodelElements.ShouldNotBeNull(); + submodel.SubmodelElements.ShouldContain(e => e.IdShort == "temperature"); + } + + [Fact] + public void AddSubmodelFragment_WithoutBaseSubmodel_ShouldThrowInvalidOperationException() + { + // Arrange + var builder = CreateSut(); + var missingId = _fixture.Create(); + builder.AddSubmodelFragment(missingId, fragment => fragment.AddProperty("p", "v")); + + // Act + var act = () => builder.Build(); + + // Assert + var ex = Should.Throw(act); + ex.Message.ShouldContain("No base submodel"); + } + [Fact] public void Build_WhenCalledMultipleTimes_ShouldReturnNewEnvironmentInstancesWithSameContent() { diff --git a/FluentAAS/FluentAAS.Builder/AasBuilder.cs b/FluentAAS/FluentAAS.Builder/AasBuilder.cs index d58db88..830218c 100644 --- a/FluentAAS/FluentAAS.Builder/AasBuilder.cs +++ b/FluentAAS/FluentAAS.Builder/AasBuilder.cs @@ -1,3 +1,5 @@ +using FluentAAS.Builder.SubModel; + namespace FluentAAS.Builder; /// @@ -6,8 +8,9 @@ namespace FluentAAS.Builder; /// public sealed class AasBuilder : IAasBuilder { - private readonly List _shells = []; - private readonly List _submodels = []; + private readonly List _shells = []; + private readonly List _submodels = []; + private readonly List<(string SubmodelId, Action Fn)> _submodelFragments = []; /// /// Initializes a new instance of the class. @@ -66,6 +69,22 @@ public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetK return new ShellBuilder(this, shell); } + /// + /// Adds an existing, fully constructed to the environment. + /// The submodel participates in build-time uniqueness validation. + /// + /// The submodel instance to add. + /// The current instance for fluent chaining. + /// Thrown when is null. + /// + /// Thrown when required submodel fields are invalid. + /// + public IAasBuilder AddSubmodel(Submodel submodel) + { + AddSubmodelInternal(submodel); + return this; + } + /// /// Adds an existing to the environment. /// @@ -74,7 +93,33 @@ public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetK /// Thrown when is null. public IAasBuilder AddExistingSubmodel(Submodel submodel) { - AddSubmodelInternal(submodel); + return AddSubmodel(submodel); + } + + /// + /// Adds a staged fragment to an already registered submodel, identified by submodel id. + /// Fragments are applied in registration order during . + /// + /// The identifier of the target submodel. + /// Callback that contributes submodel elements. + /// The current instance for fluent chaining. + /// Thrown when is null, empty, or whitespace. + /// Thrown when is null. + public IAasBuilder AddSubmodelFragment(string submodelId, Action configure) + { + if (string.IsNullOrWhiteSpace(submodelId)) + { + throw new ArgumentException("Submodel id must not be empty.", nameof(submodelId)); + } + + ArgumentNullException.ThrowIfNull(configure); + + _submodelFragments.Add((submodelId, submodel => + { + var fragment = new SubmodelFragmentBuilder(submodel); + configure(fragment); + })); + return this; } @@ -87,6 +132,51 @@ public IAasBuilder AddExistingSubmodel(Submodel submodel) /// public IEnvironment Build() { + var duplicateSubmodelIds = _submodels.OfType() + .GroupBy(s => s.Id, StringComparer.Ordinal) + .Where(g => !string.IsNullOrWhiteSpace(g.Key) && g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateSubmodelIds.Count > 0) + { + var duplicateIdList = string.Join(", ", duplicateSubmodelIds.Select(id => $"'{id}'")); + throw new InvalidOperationException($"Duplicate submodel id(s) detected at build-time: {duplicateIdList}. Each submodel id must be unique."); + } + + foreach (var (submodelId, applyFragment) in _submodelFragments) + { + var submodel = _submodels.OfType().FirstOrDefault(sm => string.Equals(sm.Id, submodelId, StringComparison.Ordinal)); + if (submodel is null) + { + throw new InvalidOperationException($"No base submodel with id '{submodelId}' was found for a staged fragment. Add the submodel before adding fragments."); + } + + applyFragment(submodel); + } + + var knownSubmodelIds = _submodels.OfType() + .Select(s => s.Id) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var shell in _shells.OfType()) + { + foreach (var reference in shell.Submodels ?? []) + { + var referencedSubmodelId = reference.Keys.FirstOrDefault()?.Value; + if (string.IsNullOrWhiteSpace(referencedSubmodelId)) + { + throw new InvalidOperationException($"Shell '{shell.Id}' contains a submodel reference without a valid identifier."); + } + + if (!knownSubmodelIds.Contains(referencedSubmodelId)) + { + throw new InvalidOperationException($"Shell '{shell.Id}' references unknown submodel id '{referencedSubmodelId}'."); + } + } + } + // Return a fresh Environment each time, with copies of the internal lists return new Environment { @@ -120,10 +210,24 @@ public void AddShellInternal(AssetAdministrationShell shell) public void AddSubmodelInternal(Submodel submodel) { ArgumentNullException.ThrowIfNull(submodel); + ValidateSubmodel(submodel); if (!_submodels.Contains(submodel)) { _submodels.Add(submodel); } } -} \ No newline at end of file + + private static void ValidateSubmodel(Submodel submodel) + { + if (string.IsNullOrWhiteSpace(submodel.Id)) + { + throw new ArgumentException("Submodel id must not be empty.", nameof(submodel)); + } + + if (string.IsNullOrWhiteSpace(submodel.IdShort)) + { + throw new ArgumentException("Submodel idShort must not be empty.", nameof(submodel)); + } + } +} diff --git a/FluentAAS/FluentAAS.Builder/IAasBuilder.cs b/FluentAAS/FluentAAS.Builder/IAasBuilder.cs index 2fa2b7d..e3dc80b 100644 --- a/FluentAAS/FluentAAS.Builder/IAasBuilder.cs +++ b/FluentAAS/FluentAAS.Builder/IAasBuilder.cs @@ -1,3 +1,5 @@ +using FluentAAS.Builder.SubModel; + namespace FluentAAS.Builder; public interface IAasBuilder @@ -20,6 +22,18 @@ public interface IAasBuilder /// IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetKind.Instance); + /// + /// Adds an existing, fully constructed to the environment. + /// The submodel participates in build-time uniqueness validation. + /// + /// The submodel instance to add. + /// The current instance for fluent chaining. + /// Thrown when is null. + /// + /// Thrown when required submodel fields (for example ) are invalid. + /// + IAasBuilder AddSubmodel(Submodel submodel); + /// /// Adds an existing to the environment. /// @@ -28,6 +42,17 @@ public interface IAasBuilder /// Thrown when is null. IAasBuilder AddExistingSubmodel(Submodel submodel); + /// + /// Adds a staged fragment to an already registered submodel, identified by submodel id. + /// Fragments are applied in registration order during . + /// + /// The identifier of the target submodel. + /// Callback that contributes submodel elements. + /// The current instance for fluent chaining. + /// Thrown when is null, empty, or whitespace. + /// Thrown when is null. + IAasBuilder AddSubmodelFragment(string submodelId, Action configure); + /// /// Builds and returns the configured instance. /// @@ -52,4 +77,4 @@ public interface IAasBuilder /// The submodel instance to add. /// Thrown when is null. void AddSubmodelInternal(Submodel submodel); -} \ No newline at end of file +} diff --git a/FluentAAS/FluentAAS.Builder/SubModel/SubmodelFragmentBuilder.cs b/FluentAAS/FluentAAS.Builder/SubModel/SubmodelFragmentBuilder.cs new file mode 100644 index 0000000..df998e6 --- /dev/null +++ b/FluentAAS/FluentAAS.Builder/SubModel/SubmodelFragmentBuilder.cs @@ -0,0 +1,85 @@ +namespace FluentAAS.Builder.SubModel; + +/// +/// Represents a staged contribution to an existing . +/// Instances are created by and applied at build-time. +/// +public sealed class SubmodelFragmentBuilder +{ + private readonly Submodel _target; + + internal SubmodelFragmentBuilder(Submodel target) + { + _target = target ?? throw new ArgumentNullException(nameof(target)); + _target.SubmodelElements ??= []; + } + + /// + /// Adds a to the target submodel fragment. + /// + /// The short identifier of the property. + /// The property value. + /// The property value type. Defaults to . + /// The current . + /// + /// Thrown when or is null, empty, or whitespace. + /// + public SubmodelFragmentBuilder AddProperty(string idShort, string value, DataTypeDefXsd valueType = DataTypeDefXsd.String) + { + if (string.IsNullOrWhiteSpace(idShort)) + { + throw new ArgumentException("Property idShort must not be empty.", nameof(idShort)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Property value must not be empty.", nameof(value)); + } + + _target.SubmodelElements!.Add( + new Property(valueType) + { + IdShort = idShort, + Value = value + }); + + return this; + } + + /// + /// Adds a element to the target submodel. + /// + /// The short identifier of the multi-language property. + /// Configuration callback for language strings. + /// The current . + /// Thrown when is null, empty, or whitespace. + /// Thrown when is null. + public SubmodelFragmentBuilder AddMultiLanguageProperty(string idShort, Action configure) + { + if (string.IsNullOrWhiteSpace(idShort)) + { + throw new ArgumentException("Multi-language property idShort must not be empty.", nameof(idShort)); + } + + ArgumentNullException.ThrowIfNull(configure); + + var langBuilder = new LangStringSetBuilder(idShort); + configure(langBuilder); + _target.SubmodelElements!.Add(langBuilder.Build()); + + return this; + } + + /// + /// Adds an arbitrary instance to the target submodel. + /// + /// The submodel element to add. + /// The current . + /// Thrown when is null. + public SubmodelFragmentBuilder AddElement(ISubmodelElement element) + { + ArgumentNullException.ThrowIfNull(element); + _target.SubmodelElements!.Add(element); + return this; + } +} diff --git a/README.md b/README.md index 99c8e1e..af2c86c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,50 @@ You get full control when you need it. --- +## Staged Submodel Composition (`.AddSubmodel(...)`) + +For modular systems (DDD layers, plugins, stepwise enrichment), you can now register and enrich submodels at any point before `Build()`. + +### Register fully built submodels + +```csharp +var builder = AasBuilder.Create() + .AddShell("urn:aas:example:machine-42", "Machine42") + .CompleteShellConfiguration() + .AddSubmodel(new Submodel("urn:submodel:ops") { IdShort = "Operations" }) + .AddSubmodel(new Submodel("urn:submodel:maintenance") { IdShort = "Maintenance" }); +``` + +### Add staged fragments by submodel ID + +```csharp +builder + .AddSubmodelFragment("urn:submodel:ops", fragment => fragment + .AddProperty("Status", "Running") + .AddProperty("Shift", "Night")) + .AddSubmodelFragment("urn:submodel:maintenance", fragment => fragment + .AddMultiLanguageProperty("LastService", ls => ls + .Add("en", "Completed") + .Add("de", "Abgeschlossen"))); + +var environment = builder.Build(); +``` + +### Why use staged composition? + +- Build submodels in separate modules/services and attach them later. +- Support plugin-style late binding with runtime-loaded components. +- Preserve fluent chaining and existing inline builder methods. +- Keep strong validation guarantees: + - `Submodel.Id` and `Submodel.IdShort` are validated on add. + - Duplicate submodel IDs are rejected at build-time. + - Fragment targets must exist. + - Shell submodel references must resolve to known submodels. + +> Backward compatibility note: `AddExistingSubmodel(...)` still works and delegates to `AddSubmodel(...)`. + +--- + ## Project Structure | Package | What it does | @@ -268,4 +312,4 @@ MIT – free for commercial and open-source use. If you're working on AAS integration and have questions, feel free to reach out on [LinkedIn](https://www.linkedin.com/in/oliver-fries-industrie40-dotnet/). -I'm also available for consulting on .NET modernization and Industry 4.0 projects – but no pressure. The library stands on its own. \ No newline at end of file +I'm also available for consulting on .NET modernization and Industry 4.0 projects – but no pressure. The library stands on its own. From 499fc12651d0c9c8febd57c045c61c1e117c13ae Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 Mar 2026 12:00:28 +0100 Subject: [PATCH 2/2] Fix staged fragment idempotency and preserve internal submodel compatibility --- .../AasBuilderTests.cs | 41 +++++++++++++++++++ FluentAAS/FluentAAS.Builder/AasBuilder.cs | 17 ++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs b/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs index f446c91..4ccb0d0 100644 --- a/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs +++ b/FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs @@ -168,6 +168,7 @@ public void AddSubmodel_WithValidSubmodel_ShouldAddToEnvironmentAndReturnBuilder returned.ShouldBeSameAs(builder); var environment = builder.Build(); + environment.Submodels.ShouldNotBeNull(); environment.Submodels.ShouldContain(submodel); } @@ -225,6 +226,30 @@ public void AddSubmodelFragment_ShouldApplyContributionAtBuildTime() submodel.SubmodelElements.ShouldContain(e => e.IdShort == "temperature"); } + [Fact] + public void Build_WhenCalledMultipleTimes_WithFragments_ShouldNotDuplicateFragmentElements() + { + // Arrange + var builder = CreateSut(); + var submodelId = _fixture.Create(); + builder.AddSubmodel(new Submodel(id: submodelId, idShort: "composed-submodel")); + builder.AddSubmodelFragment(submodelId, fragment => fragment.AddProperty("temperature", "21.5")); + + // Act + var env1 = builder.Build(); + var env2 = builder.Build(); + + // Assert + var firstSubmodel = env1.Submodels.ShouldHaveSingleItem().ShouldBeOfType(); + var secondSubmodel = env2.Submodels.ShouldHaveSingleItem().ShouldBeOfType(); + + firstSubmodel.SubmodelElements.ShouldNotBeNull(); + secondSubmodel.SubmodelElements.ShouldNotBeNull(); + + firstSubmodel.SubmodelElements.Count(e => e.IdShort == "temperature").ShouldBe(1); + secondSubmodel.SubmodelElements.Count(e => e.IdShort == "temperature").ShouldBe(1); + } + [Fact] public void AddSubmodelFragment_WithoutBaseSubmodel_ShouldThrowInvalidOperationException() { @@ -341,4 +366,20 @@ public void AddSubmodelInternal_WithValidSubmodel_ShouldAddSubmodelToEnvironment environment.Submodels.ShouldHaveSingleItem(); environment.Submodels.Single().ShouldBeSameAs(submodel); } + + [Fact] + public void AddSubmodelInternal_WithMissingIdShort_ShouldStillAddSubmodel() + { + // Arrange + var builder = CreateSut(); + var submodel = new Submodel(id: _fixture.Create()!); + + // Act + builder.AddSubmodelInternal(submodel); + var environment = builder.Build(); + + // Assert + environment.Submodels.ShouldHaveSingleItem(); + environment.Submodels.Single().ShouldBeSameAs(submodel); + } } diff --git a/FluentAAS/FluentAAS.Builder/AasBuilder.cs b/FluentAAS/FluentAAS.Builder/AasBuilder.cs index 830218c..3ded557 100644 --- a/FluentAAS/FluentAAS.Builder/AasBuilder.cs +++ b/FluentAAS/FluentAAS.Builder/AasBuilder.cs @@ -81,6 +81,7 @@ public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetK /// public IAasBuilder AddSubmodel(Submodel submodel) { + ValidateSubmodelForPublicAdd(submodel); AddSubmodelInternal(submodel); return this; } @@ -93,7 +94,8 @@ public IAasBuilder AddSubmodel(Submodel submodel) /// Thrown when is null. public IAasBuilder AddExistingSubmodel(Submodel submodel) { - return AddSubmodel(submodel); + AddSubmodelInternal(submodel); + return this; } /// @@ -155,6 +157,8 @@ public IEnvironment Build() applyFragment(submodel); } + _submodelFragments.Clear(); + var knownSubmodelIds = _submodels.OfType() .Select(s => s.Id) .Where(id => !string.IsNullOrWhiteSpace(id)) @@ -210,7 +214,7 @@ public void AddShellInternal(AssetAdministrationShell shell) public void AddSubmodelInternal(Submodel submodel) { ArgumentNullException.ThrowIfNull(submodel); - ValidateSubmodel(submodel); + ValidateSubmodelId(submodel); if (!_submodels.Contains(submodel)) { @@ -218,12 +222,19 @@ public void AddSubmodelInternal(Submodel submodel) } } - private static void ValidateSubmodel(Submodel submodel) + private static void ValidateSubmodelId(Submodel submodel) { + ArgumentNullException.ThrowIfNull(submodel); + if (string.IsNullOrWhiteSpace(submodel.Id)) { throw new ArgumentException("Submodel id must not be empty.", nameof(submodel)); } + } + + private static void ValidateSubmodelForPublicAdd(Submodel submodel) + { + ValidateSubmodelId(submodel); if (string.IsNullOrWhiteSpace(submodel.IdShort)) {