mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
@@ -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<TConcrete>() 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>
|
||||
|
||||
Reference in New Issue
Block a user