Complete fluent node manager extensibility (#4418)

# Description

Complete the fluent node-manager extensibility surface needed by dynamic
and externally backed address spaces.

This change:

- adds virtual node families with cheap NodeId ownership checks, async
materialization, per-operation caching, custom browsing, and
family-level callbacks;
- adds monitored-item pre-creation decisions, complete lifecycle hooks,
async batch hooks, stack-managed custom monitored items, and
subscription-gated polling;
- exposes the startup `ApplicationConfiguration` to generated manager
partials through `FluentNodeManagerBase`;
- fixes typed builder proxy support, concrete object wrapper types, and
fully-qualified generated model composition;
- reports unresolved `[NodeManager]` namespace expressions as
`MODELGEN035` instead of silently dropping them;
- documents the new APIs and compatibility behavior.

## Related Issues

- Fixes #4397
- Fixes #4398
- Fixes #4399
- Fixes #4403
- Fixes #4404
- Fixes #4405

## Validation

- `Opc.Ua.Server.Tests` on `net10.0`: 4,886 passed, 5 skipped.
- `Opc.Ua.SourceGeneration.Core.Tests` on `net10.0`: 3,807 passed, 8
skipped.
- `Opc.Ua.SourceGeneration.Tests` on `net10.0`: 164 passed.
- Runtime and generator regression selections passed on `net48`.
- Review-feedback validation: 376 fluent tests passed on `net10.0`; 22
focused tests passed on `net48`.
- The CI timing regression was repeated five times on `net48` and once
on `net10.0`.
- All 266 applicable GitHub Actions and Azure Pipelines checks,
including CodeQL and coverage, pass on `83c571423`.
- AppVeyor reports an immediate zero-duration no-run failure because
`appveyor.yml` whitelists only the `appveyor` branch; it did not execute
this PR.
- `UA.slnx` builds successfully for `net10.0`; affected project builds
are warning-free.
- The repository-wide `dotnet test UA.slnx` invocation is currently
blocked by the solution's mixed VSTest/Microsoft.Testing.Platform
configuration under the .NET 10 SDK, so the affected test projects were
run individually.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] 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.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Copilot-Session: afaa67c9-fc97-4185-a785-edd54a937d95
This commit is contained in:
Marc Schier
2026-09-05 09:25:49 +02:00
committed by GitHub
parent 90fc3cca2f
commit 02ea934a31
34 changed files with 6091 additions and 91 deletions
@@ -1126,7 +1126,7 @@ namespace Opc.Ua.SourceGeneration
System.Xml.XmlQualifiedName typeDef = child?.TypeDefinition;
if (typeDef == null || string.IsNullOrEmpty(typeDef.Name))
{
return ResolveStateClrType(child);
return ResolveFallbackStateClrType(child);
}
if (child.TypeDefinitionNode is ObjectTypeDesign objectType)
{
@@ -1141,6 +1141,23 @@ namespace Opc.Ua.SourceGeneration
: "global::" + prefix + "." + stateName;
}
private static string ResolveFallbackStateClrType(NodeDesign node)
{
if (node is ObjectDesign)
{
return "global::Opc.Ua.BaseObjectState";
}
if (node is MethodDesign)
{
return "global::Opc.Ua.MethodState";
}
if (node is VariableDesign)
{
return "global::Opc.Ua.BaseDataVariableState";
}
return "global::Opc.Ua.NodeState";
}
/// <summary>
/// Maps a model namespace URI (e.g. <c>http://opcfoundation.org/UA/Pumps/</c>)
/// to the C# namespace prefix declared in the model's
@@ -1837,22 +1854,11 @@ namespace Opc.Ua.SourceGeneration
/// </summary>
private string ResolveStateClrType(NodeDesign node)
{
// For object instances, use BaseObjectState as the lowest common
// denominator. The user can call .Builder.As&lt;TConcrete&gt;() to
// narrow.
if (node is ObjectDesign)
if (node is ObjectDesign objectDesign)
{
return "global::Opc.Ua.BaseObjectState";
return ResolveChildStateClr(objectDesign);
}
if (node is MethodDesign)
{
return "global::Opc.Ua.MethodState";
}
if (node is VariableDesign)
{
return "global::Opc.Ua.BaseDataVariableState";
}
return "global::Opc.Ua.NodeState";
return ResolveFallbackStateClrType(node);
}
/// <summary>
@@ -118,7 +118,7 @@ namespace Opc.Ua.SourceGeneration
var resources = new List<Resource>(2)
{
EmitNodeManager(targetNamespace, targetClass, typeStem, nsUriSymbol, fileStem)
EmitNodeManager(nsPrefix, targetNamespace, targetClass, typeStem, nsUriSymbol, fileStem)
};
if (EmitFactory)
{
@@ -128,6 +128,7 @@ namespace Opc.Ua.SourceGeneration
}
private TextFileResource EmitNodeManager(
string modelNamespace,
string targetNamespace,
string targetClass,
string typeStem,
@@ -142,6 +143,7 @@ namespace Opc.Ua.SourceGeneration
using var templateWriter = new TemplateWriter(writer);
var template = new Template(templateWriter, NodeManagerTemplates.File);
template.AddReplacement(Tokens.NamespacePrefix, targetNamespace);
template.AddReplacement(Tokens.Prefix, modelNamespace);
template.AddReplacement(Tokens.Namespace, typeStem);
template.AddReplacement(Tokens.NodeManagerClassName, targetClass);
template.AddReplacement(Tokens.NamespaceUri, nsUriSymbol);
@@ -93,7 +93,9 @@ namespace Opc.Ua.SourceGeneration
global::System.Threading.CancellationToken cancellationToken = default)
{
return new global::System.Threading.Tasks.ValueTask<global::Opc.Ua.NodeStateCollection>(
new global::Opc.Ua.NodeStateCollection().Add{{Tokens.Namespace}}(context));
global::{{Tokens.Prefix}}.{{Tokens.Namespace}}Extensions.Add{{Tokens.Namespace}}(
new global::Opc.Ua.NodeStateCollection(),
context));
}
/// <inheritdoc/>
@@ -163,19 +165,6 @@ namespace Opc.Ua.SourceGeneration
await base.RemovePredefinedNodeAsync(context, node, referencesToRemove, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
protected override void OnMonitoredItemCreated(
global::Opc.Ua.Server.ServerSystemContext context,
global::Opc.Ua.Server.NodeHandle handle,
global::Opc.Ua.Server.ISampledDataChangeMonitoredItem monitoredItem)
{
base.OnMonitoredItemCreated(context, handle, monitoredItem);
if (__m_builder is { } __b && handle?.Node is { } __node)
{
__b.Dispatcher.NotifyMonitoredItemCreated(context, __node, monitoredItem);
}
}
private global::Opc.Ua.NodeState __FindRootByBrowseName(global::Opc.Ua.QualifiedName browseName)
{
if (browseName == null)
@@ -28,3 +28,4 @@ MODELGEN031 | ModelSourceGenerator | Error | WoT model could not be converted to
MODELGEN032 | ModelSourceGenerator | Warning | WoT model conversion produced a warning
MODELGEN033 | ModelSourceGenerator | Info | WoT model conversion note
MODELGEN034 | ModelSourceGenerator | Error | WoT model virtual NodeSet2 path collides with another input
MODELGEN035 | ModelSourceGenerator | Error | [NodeManager] namespace URI could not be resolved
@@ -142,6 +142,27 @@ namespace Opc.Ua.SourceGeneration
{
continue;
}
if (!discovery.InvalidExpressions.IsDefaultOrEmpty)
{
string targetType = string.IsNullOrEmpty(
discovery.Binding.TargetNamespace)
? discovery.Binding.TargetClassName
: discovery.Binding.TargetNamespace +
"." +
discovery.Binding.TargetClassName;
foreach (NodeManagerAttributeExpressionError error in
discovery.InvalidExpressions)
{
m_context.ReportDiagnostic(
Diagnostic.Create(
SourceGenerator.NodeManagerArgumentUnresolved,
error.Location,
error.ArgumentName,
error.Expression,
targetType));
}
continue;
}
if (!discovery.IsPartial)
{
m_context.ReportDiagnostic(
@@ -27,6 +27,8 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
@@ -59,6 +61,11 @@ namespace Opc.Ua.SourceGeneration
/// </summary>
public bool IsPartial { get; init; }
/// <summary>
/// Attribute expressions that Roslyn could not bind to constants.
/// </summary>
public ImmutableArray<NodeManagerAttributeExpressionError> InvalidExpressions { get; init; }
/// <summary>
/// Predicate used by <see cref="SyntaxProvider.ForAttributeWithMetadataName"/>.
/// </summary>
@@ -82,6 +89,8 @@ namespace Opc.Ua.SourceGeneration
string design = attr.GetValue(nameof(NodeManagerAttributeBinding.Design));
string[] additionalNamespaceUris = attr.GetStringArray(
nameof(NodeManagerAttributeBinding.AdditionalNamespaceUris));
ImmutableArray<NodeManagerAttributeExpressionError> invalidExpressions =
GetInvalidExpressions(attr, context.SemanticModel, cancellationToken);
bool generateFactory = attr == null ||
!attr.NamedArguments
.Any(p => p.Key == nameof(NodeManagerAttributeBinding.GenerateFactory) &&
@@ -110,8 +119,130 @@ namespace Opc.Ua.SourceGeneration
AdditionalNamespaceUris = additionalNamespaceUris
},
Location = location,
IsPartial = isPartial
IsPartial = isPartial,
InvalidExpressions = invalidExpressions
};
}
private static ImmutableArray<NodeManagerAttributeExpressionError> GetInvalidExpressions(
AttributeData attribute,
SemanticModel semanticModel,
CancellationToken cancellationToken)
{
if (attribute?.ApplicationSyntaxReference?.GetSyntax(cancellationToken) is not
AttributeSyntax attributeSyntax)
{
return [];
}
var errors = ImmutableArray.CreateBuilder<NodeManagerAttributeExpressionError>();
AddInvalidExpression(
errors,
attribute,
attributeSyntax,
semanticModel,
nameof(NodeManagerAttributeBinding.NamespaceUri),
isArray: false,
cancellationToken);
AddInvalidExpression(
errors,
attribute,
attributeSyntax,
semanticModel,
nameof(NodeManagerAttributeBinding.AdditionalNamespaceUris),
isArray: true,
cancellationToken);
return errors.ToImmutable();
}
private static void AddInvalidExpression(
ImmutableArray<NodeManagerAttributeExpressionError>.Builder errors,
AttributeData attribute,
AttributeSyntax attributeSyntax,
SemanticModel semanticModel,
string argumentName,
bool isArray,
CancellationToken cancellationToken)
{
KeyValuePair<string, TypedConstant> namedArgument = attribute.NamedArguments
.FirstOrDefault(argument => argument.Key == argumentName);
if (namedArgument.Key == null)
{
return;
}
TypedConstant value = namedArgument.Value;
bool hasError = value.Kind == TypedConstantKind.Error ||
(isArray &&
value.Kind == TypedConstantKind.Array &&
!value.IsNull &&
value.Values.Any(element => element.Kind == TypedConstantKind.Error));
if (!hasError)
{
return;
}
AttributeArgumentSyntax argumentSyntax = attributeSyntax.ArgumentList?.Arguments
.FirstOrDefault(argument =>
argument.NameEquals?.Name.Identifier.ValueText == argumentName);
if (argumentSyntax == null)
{
return;
}
if (isArray)
{
ExpressionSyntax[] elementExpressions =
GetArrayElementExpressions(argumentSyntax.Expression);
if (elementExpressions.Length > 0)
{
int initialCount = errors.Count;
for (int ii = 0; ii < elementExpressions.Length; ii++)
{
ExpressionSyntax elementExpression = elementExpressions[ii];
bool isInvalid = namedArgument.Value.Kind == TypedConstantKind.Array &&
ii < namedArgument.Value.Values.Length
? namedArgument.Value.Values[ii].Kind == TypedConstantKind.Error
: !semanticModel.GetConstantValue(
elementExpression,
cancellationToken).HasValue;
if (isInvalid)
{
errors.Add(new NodeManagerAttributeExpressionError(
argumentName,
elementExpression.ToString(),
elementExpression.GetLocation()));
}
}
if (errors.Count > initialCount)
{
return;
}
}
}
errors.Add(new NodeManagerAttributeExpressionError(
argumentName,
argumentSyntax.Expression.ToString(),
argumentSyntax.Expression.GetLocation()));
}
private static ExpressionSyntax[] GetArrayElementExpressions(ExpressionSyntax expression)
{
SeparatedSyntaxList<ExpressionSyntax>? expressions = expression switch
{
ArrayCreationExpressionSyntax array => array.Initializer?.Expressions,
ImplicitArrayCreationExpressionSyntax array => array.Initializer.Expressions,
_ => null
};
return expressions.HasValue ? [.. expressions.Value] : [];
}
}
/// <summary>
/// An unresolved constant expression in a <c>[NodeManager]</c> attribute.
/// </summary>
internal sealed record class NodeManagerAttributeExpressionError(
string ArgumentName,
string Expression,
Location Location);
}
@@ -354,6 +354,23 @@ namespace Opc.Ua.SourceGeneration
helpLinkUri: "www.opcfoundation.org",
customTags: ["opcua"]);
/// <summary>
/// A <c>[NodeManager]</c> namespace URI expression could not be
/// resolved before source generation.
/// </summary>
public static readonly DiagnosticDescriptor NodeManagerArgumentUnresolved = new(
id: "MODELGEN035",
title: "[NodeManager] namespace URI could not be resolved",
messageFormat: (LocalizableString)("The {0} expression '{1}' on [NodeManager] " +
"type '{2}' could not be resolved during source generation. Use a string " +
"literal or a const declared in source or a referenced assembly; values " +
"generated in the same compilation are unavailable."),
category: Name,
DiagnosticSeverity.Error,
isEnabledByDefault: true,
helpLinkUri: "www.opcfoundation.org",
customTags: ["opcua"]);
/// <summary>
/// Get diagnostic descriptor for event id
/// </summary>