Skip to content

Add node creation to the fluent node manager builder - #4428

Merged
marcschier merged 6 commits into
masterfrom
romanett/fluent-node-creation
Sep 7, 2026
Merged

Add node creation to the fluent node manager builder#4428
marcschier merged 6 commits into
masterfrom
romanett/fluent-node-creation

Conversation

@romanett

@romanett romanett commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Description

INodeManagerBuilder gains a node creation surface alongside its existing lookup surface, so a Configure partial can fill a namespace without a NodeSet or a ModelDesign.

Twelve new members, implemented as ordinary public members on NodeManagerBuilder (new partial file NodeManagerBuilder.Authoring.cs):

Member Creates
AddFolder(name, parentId) a FolderState (Organizes)
AddObject(name, parentId, typeDefinitionId) a BaseObjectState
AddVariable<TValue>(name, parentId) a BaseDataVariableState whose DataType/ValueRank come from TValue
AddMethod(name, parentId) an executable MethodState
Add<TState>(node, parentId) an already-constructed state of any NodeState subclass
Add<TState>(factory, parentId) a state built by a factory that receives the resolved parent
AddRoot<TState>(node) a root, with its existing references left alone
TryGetNode(nodeId, out node) lookup across created-but-not-yet-registered nodes and predefined nodes

Each Add* for a browse name takes a string (qualified with the manager's default namespace) or a QualifiedName carrying an explicit nonzero namespace index — eight overloads plus the four above.

partial void Configure(INodeManagerBuilder builder)
{
    INodeBuilder<FolderState> machines = builder.AddFolder("Machines");

    builder.AddVariable<double>("Pressure", machines.Node.NodeId)
        .OnRead(() => m_sensor.Pressure);

    builder.AddMethod("Reset", machines.Node.NodeId)
        .OnCall(ResetAsync);
}

Three properties make this usable straight from Configure:

  • NodeIds are final before the builder comes back. Every Add* runs the node — and its whole subtree — through the manager's INodeIdFactory before returning, so OnRead/OnWrite registrations, which key off NodeState.NodeId, stay valid once the node is registered.
  • Creation is staged, not immediate. Created nodes are held until the manager calls RegisterAuthoredNodesAsync. That is what lets a node name a sibling created moments earlier, and it keeps the builder usable before the manager has finished building its address space.
  • Custom state types stay typed. Add<TState> returns INodeBuilder<TState>, so a hand-written NodeState subclass keeps its type through the fluent chain.

Staging also makes the builder's own lookups see created nodes: browse paths, NodeId, TypeDefinitionId and DataType resolution all consult the staged graph before falling back to the manager's predefined nodes.

Registration ordering

A new protected FluentNodeManagerBase.RegisterAuthoredNodesAsync(builder, ct) hands the staged roots to AddPredefinedNodeAsync. It runs after the Configure delegates and before CompleteConfigureAsync, so the reverse-reference pass sees the new nodes and mirrors their references to externally owned nodes (typically the ns=0 Objects folder) into externalReferences. Three call sites emit it in that position:

  • the source-generated CreateAddressSpaceAsync (NodeManagerTemplates)
  • the hosting FluentNodeManager (FluentNodeManagerFactory)
  • RuntimeNodeSetNodeManager, which additionally re-runs AddReverseReferencesAsync because its first pass happens before Configure

A builder that created nothing registers nothing, so the call is safe to make unconditionally.

The surface is available to every fluent host — there is no opt-in gate and no second builder interface. The generated typed builder (FluentBuilderGenerator) forwards the new members like the rest of INodeManagerBuilder.

Supporting change

AsyncCustomNodeManager.PrepareAuthoredNodeIdsForRegistration (internal) assigns an id to every node in a subtree that still lacks one and pulls namespace-0 children into the root's namespace. It differs from the existing PrepareInstanceNodeIdsForRegistration, which only rebases a subtree that collides with a declaration. It is idempotent.

Related Issues

Notes for reviewers

Three judgement calls worth a look:

  1. Always-on rather than gated. The feature is available to every fluent host with no enable call. There is no INodeSource-style concept here to withhold it from, so an always-on capability is the same behaviour with one fewer state flag. Misuse is still rejected: Add* after Seal() or after registration throws BadInvalidState, a NodeId in a namespace the manager does not own throws BadNodeIdInvalid, and a parent in one of the manager's own namespaces that was never created throws BadNodeIdUnknown.
  2. Detaching from the external-parent proxy goes through AddChild/RemoveChild. The factory overload hands the factory an identity-only proxy when the parent belongs to another node manager, then has to drop the parent link the factory established. BaseInstanceState.Parent has an internal setter scoped to Opc.Ua.Types, so this pair is the only public route. Commented as such at the call site. An alternative would be widening that setter or adding InternalsVisibleTo — happy to switch if preferred.
  3. PrepareNodeIds does not re-walk per descendant. The root pass already covers the subtree, so per-child calls would be no-ops on a quadratic walk; descendants are only namespace-validated.

One inherited behaviour the new tests pinned down: BaseObjectState(parent) sets ReferenceTypeId = HasComponent in its own constructor, so a node built via the factory overload lands under the Objects folder with HasComponent, while the plain AddObject path picks Organizes. That asymmetry predates this PR; flagging it in case it is worth normalizing separately.

Import(UANodeSet) is deliberately not part of this change — it belongs to NodeSet import rather than node creation.

Test results

19 new tests in tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs cover NodeId assignment before return, parent-by-NodeId nesting, the default Objects-folder placement and its inverse reference, custom state types, both factory paths (authored parent and external-parent proxy), AddRoot reference preservation, TryGetNode, id-keyed read handlers firing on the registered instance, browse-path/NodeId visibility of created nodes, and every rejection path listed above.

Run against net10.0 (-p:CustomTestTarget=net10.0):

Suite Result
Opc.Ua.Server.Tests 4926 passed, 0 failed, 5 skipped
Opc.Ua.SourceGeneration.Core.Tests 3809 passed, 0 failed, 8 skipped
Opc.Ua.SourceGeneration.Tests 165 passed, 0 failed

UA.slnx builds with 0 errors and no new warnings.

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation. (New Creating nodes from scratch — the Add* surface section in docs/NodeManagers.md, plus TOC entry.)
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed. — net10.0 only so far (results above); .NET Framework has not been run locally yet.
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings. — pending first CI run.
  • I have addressed all PR feedback received.

🤖 Generated with Claude Code

romanett and others added 2 commits September 6, 2026 17:22
INodeManagerBuilder gains a creation surface alongside its lookup
surface: AddFolder, AddObject, AddVariable<T> and AddMethod (string and
QualifiedName overloads, parent by NodeId), Add<TState> for an
already-constructed state, a factory-aware Add<TState> that resolves the
parent first, AddRoot<TState>, and TryGetNode.

Created nodes are staged on the builder and given final NodeIds through
the manager's INodeIdFactory before the per-node builder is returned, so
OnRead/OnWrite registrations — which key off NodeState.NodeId — stay
valid once the node is registered. Registration happens in
RegisterAuthoredNodesAsync, which the generated CreateAddressSpaceAsync
now calls after the Configure partials and before CompleteConfigureAsync,
so the reverse-reference pass mirrors references to externally owned
nodes (typically the Objects folder) into externalReferences. The
hosting FluentNodeManager and RuntimeNodeSetNodeManager do the same.

Staging also makes the builder's own lookups see created nodes: browse
paths, NodeId, TypeDefinitionId and DataType resolution all consult the
staged graph before falling back to the manager's predefined nodes.

The surface is available to every fluent host — there is no separate
opt-in and no second builder interface. The generated typed builder
forwards the new members like the rest of INodeManagerBuilder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Existing table-of-contents entries strip inline code markers from the
link text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.81% (240875/277466 lines) >= 70.00%
✅ Project branch rate 76.73% >= 60.00%
✅ Patch coverage 95.71% (424/443 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +13.21 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Server/Fluent/NodeManagerBuilder.Authoring.cs: 87, 88, 89, 90, 386, 388, 459, 589, 617, 618, 619, 620, 653, 654, 655
  • src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs: 395
  • src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs: 1166, 1171, 1172

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 85.90%, branch 75.86%.

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.64786% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.90%. Comparing base (62658d1) to head (deca980).

Files with missing lines Patch % Lines
...c.Ua.Server/Fluent/NodeManagerBuilder.Authoring.cs 92.07% 15 Missing and 11 partials ⚠️
...pc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs 73.07% 3 Missing and 4 partials ⚠️
src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs 60.00% 1 Missing and 1 partial ⚠️
src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs 84.61% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4428      +/-   ##
==========================================
+ Coverage   80.88%   80.90%   +0.02%     
==========================================
  Files        1984     1985       +1     
  Lines      277054   277466     +412     
  Branches    48084    48161      +77     
==========================================
+ Hits       224088   224479     +391     
- Misses      36437    36440       +3     
- Partials    16529    16547      +18     
Flag Coverage Δ
actions 80.90% <91.64%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs 90.10% <100.00%> (-0.02%) ⬇️
.../Opc.Ua.Server/Hosting/FluentNodeManagerFactory.cs 88.63% <100.00%> (-0.50%) ⬇️
...Server/RuntimeNodeSet/RuntimeNodeSetNodeManager.cs 75.10% <100.00%> (+0.40%) ⬆️
...neration.Core/Generators/FluentBuilderGenerator.cs 85.05% <100.00%> (+0.79%) ⬆️
...Generation.Core/Generators/NodeManagerTemplates.cs 100.00% <100.00%> (ø)
src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs 88.38% <60.00%> (-0.41%) ⬇️
src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs 78.23% <84.61%> (+0.11%) ⬆️
...pc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs 83.42% <73.07%> (-0.05%) ⬇️
...c.Ua.Server/Fluent/NodeManagerBuilder.Authoring.cs 92.07% <92.07%> (ø)

... and 27 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The coverage bots flagged the new authoring file at roughly 60% patch
coverage: the happy paths were tested but almost none of the validation
was.

Adds 19 tests covering the QualifiedName overloads, an explicit type
definition, staged nodes appearing in TypeDefinitionId and DataType
lookups, grandchild registration, the idempotent AddChildIfMissing and
AddRoot paths, the null-argument guards, and every rejection: empty and
namespace-0 browse names, a missing browse name, a non-instance node
given a parent, a parentId contradicting an existing parent, a parent
with no NodeId, a parent outside the graph, an unowned namespace, a
collision with a predefined node, a NodeIdFactory that assigns nothing,
and a builder not backed by an AsyncCustomNodeManager.

Two branches turned out to be unreachable rather than untested, so they
are gone instead:

- `AuthoredRoots` was never read by anything.
- `PrepareNodeIds` called the NodeId factory a second time when the id
  was still null, but `PrepareAuthoredNodeIdsForRegistration` has just
  run the same factory over the same node, so the retry could only
  repeat the result. The guard that reports a factory which assigns
  nothing stays, and is now tested.

Line coverage on NodeManagerBuilder.Authoring.cs is 92.2%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
romanett and others added 2 commits September 6, 2026 19:56
Three managers already derived from FluentNodeManagerBase and hand-rolled
what the new Add* surface does. Each now stages its root through the
builder instead, which drops the manual NodeId/reference bookkeeping and
routes the Objects-folder edge through CompleteConfigureAsync like any
other configure-created node.

- FluentNodeManagerFactory: the optional root folder is staged before the
  build delegate runs, so the delegate can reach it by NodeId or browse
  path. Its explicit string NodeId is preserved, since that is the
  address clients browse; the builder supplies the inverse Organizes
  reference that CreateRootFolder used to add itself.
- Isa95NodeManager: CreateRoot no longer takes externalReferences and no
  longer maintains the ObjectsFolder entry by hand. The root is staged
  once its JobControl endpoints are attached, so the subtree registers
  together, and the manager now runs the reverse-reference pass it
  previously skipped.
- SiteNodeManager (sample): the site folder, its areas and their
  SourceServer properties are staged in one call. Staging assigns the
  child NodeIds through the manager's own New(...) factory, replacing the
  explicit AssignInstanceChildNodeIds call, and LinkAreasToObjectsFolder
  is gone entirely.

The DI device builders were considered and rejected: DiNodeManager hands
out one long-lived, never-sealed builder and creates devices at runtime
through public APIs, whereas staging is a startup-time mechanism that
refuses Add* once the graph is registered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett marked this pull request as ready for review September 6, 2026 17:58
Copilot AI lite review requested due to automatic review settings September 6, 2026 17:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a broad new public API surface and alters node-manager address-space construction/registration ordering across multiple hosts, requiring careful human review for compatibility and behavioral edge cases.

Pull request overview

This PR extends the fluent node manager builder (INodeManagerBuilder) with a staged node-creation API (“Add* surface”), enabling Configure partials to create and wire up new nodes without requiring a NodeSet or ModelDesign. It integrates registration of staged nodes into the fluent address-space build pipeline so reverse-reference mirroring includes newly authored nodes, and adds tests + documentation for the new behavior.

Changes:

  • Add staged node-creation members to INodeManagerBuilder and implement them in NodeManagerBuilder.Authoring.cs (folders/objects/variables/methods, generic add/root, factory overload, staged lookups).
  • Introduce RegisterAuthoredNodesAsync(...) into fluent build flows (generated templates, hosting factory, runtime nodeset manager) to register staged nodes before reverse-reference processing.
  • Add comprehensive NUnit coverage and documentation updates describing the new authoring surface and its lifecycle/ordering.
File summaries
File Description
tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs Emit authored-node registration in generated CreateAddressSpaceAsync before reverse-reference pass.
tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs Forward new INodeManagerBuilder creation members through the typed builder generator.
src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs Define the new public creation and staged-lookup API surface.
src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs Make builder partial and route browse-path root resolution through staged roots first; include authored nodes in type/data-type/node-id resolution.
src/Opc.Ua.Server/Fluent/NodeManagerBuilder.Authoring.cs Implement staging, parent attachment, NodeId preparation, indexing, registration handoff, and Add* methods.
src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs Add protected helper to register authored nodes via the builder at the correct point in the pipeline.
src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs Add subtree NodeId preparation pass for authored nodes to ensure stable NodeIds before returning builders.
src/Opc.Ua.Server/Hosting/FluentNodeManagerFactory.cs Stage optional root folder via builder and register authored nodes before reverse-reference mirroring.
src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetNodeManager.cs Register authored nodes and rerun reverse-reference pass so external references include configure-created nodes.
src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs Switch root registration to staged builder flow and remove manual externalReferences bookkeeping.
samples/OpenUsd/SiteCompositionServer/SiteNodeManager.cs Stage site topology subtree via builder and rely on reverse-reference pass for Objects-folder linkage.
tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs Add test suite covering staging semantics, NodeId stability, parent resolution, factory behavior, rejection paths, and lookup visibility.
docs/NodeManagers.md Document the new Add* authoring surface, staging/registration ordering, and usage pattern.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

PrepareAuthoredNodeIdsForRegistration only reassigned when a node's id
was null, or when a descendant sat in namespace 0 under a non-zero root.
A subtree materialised from a type model with
NodeState.Create(..., assignNodeIds: false) matches neither: Create
forwards to CreateInternal, which skips AssignNodeIds entirely, so the
children keep their declaration ids — non-null, and in the model's own
namespace rather than ns 0.

Staging such a subtree therefore hit the declaration nodes already in
PredefinedNodes and threw BadNodeIdExists from Add, rejecting a subtree
that AddPredefinedNodeAsync accepts: its own
PrepareInstanceNodeIdsForRegistration repairs exactly this case through
HasDeclarationNodeIdCollision. Where the declarations are not indexed in
the manager, the divergence was quieter but worse — the ids would have
been rebased later at registration, after the builder had already handed
them back for callback wiring.

Adding the same collision check to the authored pass makes the two agree
and keeps the ids final when the per-node builder returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcschier
marcschier merged commit 39602bc into master Sep 7, 2026
271 checks passed
@marcschier
marcschier deleted the romanett/fluent-node-creation branch September 7, 2026 05:53
romanett added a commit that referenced this pull request Sep 7, 2026
Master added node creation to the fluent builder (#4428), which stages
authored nodes for the same lookups this branch stages imported nodes
for.

- NodeId lookups consult the import batch first, then the authored
  nodes, then the manager's predefined nodes.
- The type- and DataType-keyed lookups now go through master's
  CollectAuthoredCandidates, extended to include the imported nodes and
  to drop a candidate an import is about to displace.
- RuntimeNodeSetNodeManager registers the configuration's authored nodes
  first and then completes the import batch through CompleteConfigureAsync,
  matching the order the generated manager uses. Imported children can
  therefore attach to nodes the same Configure pass created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants