Skip to content

Seal [DataType] config classes and migrate remaining [DataContract] configs - #783

Merged
romanett merged 4 commits into
masterfrom
romanett/migrate-sample-configs-to-datatype
Aug 26, 2026
Merged

Seal [DataType] config classes and migrate remaining [DataContract] configs#783
romanett merged 4 commits into
masterfrom
romanett/migrate-sample-configs-to-datatype

Conversation

@romanett

@romanett romanett commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

AreaConfiguration (AlarmCondition server) is a non-sealed partial class. The source generator's default StructureHandling.Auto resolves to inline encoding only when the field's type is sealed and has no IEncodeable base — otherwise it wraps the value in an ExtensionObject to allow subtyping.

AreaConfiguration is the element type of two arrays, neither of which sets StructureHandling:

  • AlarmConditionServerConfiguration.Areas
  • AreaConfiguration.SubAreas (self-recursive)

So both were generated as WriteEncodeableArrayAsExtensionObjects / ReadEncodeableArrayAsExtensionObjects, while the shipped AlarmConditionServer.Config.xml stores plain nested <AreaConfiguration> elements with no ExtensionObject/TypeId wrappers.

The result was a silent failure — the area tree decoded as empty, no exception:

BEFORE  --- total areas decoded: 0 (expected 8) ---
AFTER   --- total areas decoded: 8 (expected 8) ---

Changes

Seal the 10 existing [DataType] types. AreaConfiguration, MemoryBufferInstance and PerformanceTestResultItem are used as property types, so only those change generated encoding. The rest are sealed for consistency, so the same bug cannot reappear when someone adds a nested property. None of these types is subclassed anywhere in the repo, so sealing is compile-safe.

Migrate the 9 remaining [DataContract] server configs to [DataType] sealed partial classes — Aggregation, Boiler, DataAccess, DataTypes, Empty, HistoricalEvents, Methods, PerfTest, SimpleEvents. The [OnDeserializing] Initialize(StreamingContext) hooks and now-unused System.Runtime.Serialization / System.ServiceModel usings are dropped, and the missing using Opc.Ua; added. The same residual scaffolding is cleaned from the already-migrated configs (Views, UserAuthentication, MemoryBuffer, TestData, HistoricalAccess).

Add the OPCFoundation.NetStandard.Opc.Ua.SourceGeneration analyzer reference to 9 projects. [DataType] without the analyzer generates nothing. Analyzers do not flow through ProjectReference, so nothing else supplied it. Note that HistoricalAccess Server already carried [DataType]/[DataTypeField] annotations that no generator ever acted on. No package version bump — the pinned 2.0.158.59919-preview already contains the sealed/StructureHandling support.

Re-enable ParseExtension<T> in nine node managers. These call sites had been stubbed to m_configuration = null; during the 2.0 migration, because ParseExtension<T> constrains T to IEncodeable, new() and the configs were plain [DataContract] POCOs. Now that they are source-generated IEncodeable types, the real call is restored in Boiler, DataAccess, DataTypes, Empty, HistoricalEvents, Methods, PerfTest, SimpleEvents and HistoricalAccess. The existing "use suitable defaults if no configuration exists" fallback is kept.

Two of those did not actually bind and needed their namespaces reconciled first. Both failed silently, falling back to defaults rather than erroring:

  • HistoricalAccess used a bare [DataType], so its namespace defaulted to urn:quickstarts.historicalaccessserver, while the shipped config declares http://opcfoundation.org/Quickstarts/HistoricalAccess. This is the only one of the nine carrying real data, so ArchiveRoot silently stayed null instead of reading .\Archive. Now bound explicitly to Namespaces.HistoricalAccess.
  • DataTypesServer.Config.xml declared xmlns="http://somecompany.com/DataTypes", which matches no constant in the project. Corrected to urn:localhost:somecompany.com:DataTypesServer — the value of Quickstarts.DataTypes.Namespaces.DataTypes and the server's own primary node namespace — making it consistent with the other eight samples.

Deliberately not changed

UA Sample Controls gets no analyzer reference. PerformanceTestResult is still actively round-tripped through DataContractSerializer in PerformanceTestDlg.LoadResults/SaveResults, and its TestCaseResults is a private List<T> rather than ArrayOf<T>. Only the sealing is applied there, and its live serializer hooks are left intact rather than reshaping unrelated UI sample code.

AggregationServerConfiguration keeps no ParseExtension call: it has no node manager field, no consumer, and no element in Quickstarts.AggregationServer.Config.xml, so there is nothing to restore. (AggregationServer.cs separately parses ConfiguredEndpointCollection, which is unaffected.)

Verification

  • All touched projects build with 0 errors.
  • Generated encoder for AlarmConditionServerConfiguration switched from WriteEncodeableArrayAsExtensionObjects to WriteEncodeableArray for both Areas and SubAreas; zero AsExtensionObject calls remain.
  • Loading the real AlarmConditionServer.Config.xml through ParseExtension<AlarmConditionServerConfiguration>() decodes the full three-level tree (Green/Yellow → East/West → Red/Blue with 2 SourcePaths each) — 8 areas, versus 0 on master.
  • Loading each of the nine shipped Config.xml files and calling ParseExtension<T> returns a bound IEncodeable instance in every case, and HistoricalAccess reads ArchiveRoot = ".\Archive":
Boiler             BOUND    ParseExtension<BoilerServerConfiguration> -> IEncodeable=True
DataAccess         BOUND    ParseExtension<DataAccessServerConfiguration> -> IEncodeable=True
DataTypes          BOUND    ParseExtension<DataTypesServerConfiguration> -> IEncodeable=True
Empty              BOUND    ParseExtension<EmptyServerConfiguration> -> IEncodeable=True
HistoricalEvents   BOUND    ParseExtension<HistoricalEventsServerConfiguration> -> IEncodeable=True
Methods            BOUND    ParseExtension<MethodsServerConfiguration> -> IEncodeable=True
PerfTest           BOUND    ParseExtension<PerfTestServerConfiguration> -> IEncodeable=True
SimpleEvents       BOUND    ParseExtension<SimpleEventsServerConfiguration> -> IEncodeable=True
HistoricalAccess   BOUND    ArchiveRoot = ".\Archive"
  • Reverting either namespace fix reproduces the silent null on that sample, confirming both were required.

Note for reviewers

Eight of the nine migrated configs are empty (no fields), so restoring ParseExtension changes no observable behavior for them — it removes dead stubs and makes the samples demonstrate the intended pattern again. HistoricalAccess is the exception: it is a real behavior fix, since ArchiveRoot from the config file was previously being ignored.

🤖 Generated with Claude Code

romanett and others added 2 commits August 26, 2026 06:35
…onfigs

AreaConfiguration was a non-sealed partial class, so the source generator's
default StructureHandling.Auto resolved to ExtensionObject wrapping for both
AlarmConditionServerConfiguration.Areas and AreaConfiguration.SubAreas. The
shipped AlarmConditionServer.Config.xml stores plain nested <AreaConfiguration>
elements with no ExtensionObject/TypeId wrappers, so the area tree silently
decoded as empty (0 of 8 areas, no exception).

Auto resolves to inline encoding only when the field's type is sealed and has
no IEncodeable base; otherwise it wraps in an ExtensionObject to allow
subtyping. Sealing the config types is the fix, and none of them is subclassed
anywhere in the repo.

- Seal the 10 existing [DataType] types. AreaConfiguration, MemoryBufferInstance
  and PerformanceTestResultItem are used as property types, so only those change
  generated encoding; the rest are sealed for consistency so the same bug cannot
  reappear when a nested property is added.
- Migrate the 9 remaining [DataContract] server configs to [DataType] sealed
  partial classes and drop the DataContractSerializer-only [OnDeserializing]
  hooks and now-unused usings. All 9 are empty (no [DataMember] properties) and
  none is ever deserialized - each node manager hardcodes m_configuration = null
  and falls back to a new instance - so this is a format change only.
- Add the OPCFoundation.NetStandard.Opc.Ua.SourceGeneration analyzer reference
  to the 9 projects that need it. Analyzers do not flow through ProjectReference,
  and HistoricalAccess Server already carried [DataType] annotations that no
  generator ever acted on.

UA Sample Controls deliberately keeps no analyzer reference: PerformanceTestResult
is still round-tripped through DataContractSerializer in PerformanceTestDlg, and
its TestCaseResults is a private List<T> rather than ArrayOf<T>. Only the sealing
is applied there and its live serializer hooks are left intact.

Verified: AlarmCondition config now decodes all 8 areas across three levels of
nesting (0 before), the generated encoder switched from
WriteEncodeableArrayAsExtensionObjects to WriteEncodeableArray, and
ParseExtension<BoilerServerConfiguration> now returns an IEncodeable instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now that these config types are source-generated IEncodeable implementations,
ParseExtension<T> can bind them again. It constrains T to IEncodeable, new(),
which is why these call sites were stubbed out to m_configuration = null during
the 2.0 migration.

Replace the stub with the real call in nine node managers: Boiler, DataAccess,
DataTypes, Empty, HistoricalEvents, Methods, PerfTest, SimpleEvents and
HistoricalAccess. The existing "use suitable defaults if no configuration
exists" fallback is left in place.

Two of them did not actually bind and needed their namespaces reconciled first;
both failed silently, falling back to defaults rather than raising an error:

- HistoricalAccess used a bare [DataType], so its namespace defaulted to
  urn:quickstarts.historicalaccessserver while the shipped config declares
  http://opcfoundation.org/Quickstarts/HistoricalAccess. This is the only one
  of the nine that carries real data, so ArchiveRoot silently stayed null
  instead of reading ".\Archive". Bound explicitly to Namespaces.HistoricalAccess.
- DataTypesServer.Config.xml declared xmlns="http://somecompany.com/DataTypes",
  which matches no constant in the project. Corrected to
  urn:localhost:somecompany.com:DataTypesServer, the value of
  Quickstarts.DataTypes.Namespaces.DataTypes and the server's own primary node
  namespace, making it consistent with the other eight samples.

AggregationServerConfiguration is deliberately untouched: it has no node manager
field, no consumer, and no element in Quickstarts.AggregationServer.Config.xml,
so there is no ParseExtension call to restore.

Verified by loading each shipped Config.xml and calling ParseExtension<T>: all
nine now bind and return an IEncodeable instance, and HistoricalAccess reads
ArchiveRoot = ".\Archive". Reverting either namespace fix reproduces the silent
null on that sample.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@romanett
romanett merged commit 07f198b into master Aug 26, 2026
2 of 7 checks passed
Merging master brought in the package bump from #785, but the nine
SourceGeneration analyzer references this branch adds exist only here, so the
merge left them pinned to 2.0.158.59919-preview - a version the opcua-preview
feed no longer publishes. Git could not flag it as a conflict because nothing
on master touches those lines.

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.

1 participant