Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 372 additions & 0 deletions Opc.Ua.JsonNodeSet/AddressSpace.Variants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
using Newtonsoft.Json.Linq;
using Opc.Ua.JsonNodeSet.Model;

namespace Opc.Ua.JsonNodeSet;

/// <summary>
/// Variant canonicalization for <see cref="AddressSpace"/>.
///
/// <para>Responsibilities:</para>
/// <list type="bullet">
/// <item>Maintain indexes that allow schema-aware Variant emission:
/// <c>_encodingToDataType</c>, <c>_dataTypeToXmlEncoding</c>, <c>_modelXmlNamespace</c>.</item>
/// <item>Strip redundant "Default JSON" encoding nodes on request.</item>
/// <item><see cref="ResolveVariants"/>: rewrite every <see cref="UAVariable"/>'s Variant DOM so
/// that structure <see cref="JObject"/> keys are in <see cref="DataTypeDefinition"/> order,
/// normalize XML encoding <c>TypeId</c>s to DataType NodeIds, and validate unions.</item>
/// </list>
/// </summary>
public partial class AddressSpace
{
private const string HasEncodingRefId = "i=38";
private const string DefaultXmlBrowseName = "Default XML";
private const string DefaultJsonBrowseName = "Default JSON";
private const string DefaultBinaryBrowseName = "Default Binary";

// encoding NodeId → DataType NodeId
private Dictionary<string, string>? _encodingToDataType;
// DataType NodeId → "Default XML" encoding NodeId
private Dictionary<string, string>? _dataTypeToXmlEncoding;
// model ModelUri → XmlSchemaUri
private Dictionary<string, string>? _modelXmlNamespace;
// XmlSchemaUri → ModelUri (reverse)
private Dictionary<string, string>? _xmlNamespaceToModel;
// DataType BrowseName (bare) → DataType NodeId (first match wins)
private Dictionary<string, string>? _dataTypeBrowseNameIndex;

/// <summary>
/// (Re)builds the variant-related indexes. Called lazily from the Try... lookups, and
/// invoked explicitly by <see cref="ResolveVariants"/>. Cheap to call — O(nodes).
/// </summary>
public void BuildVariantIndexes()
{
_encodingToDataType = new Dictionary<string, string>(StringComparer.Ordinal);
_dataTypeToXmlEncoding = new Dictionary<string, string>(StringComparer.Ordinal);
_modelXmlNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
_xmlNamespaceToModel = new Dictionary<string, string>(StringComparer.Ordinal);
_dataTypeBrowseNameIndex = new Dictionary<string, string>(StringComparer.Ordinal);

// Model XML namespace map: prefer explicit XmlSchemaUri, otherwise synthesize
// "{ModelUri}/Types.xsd" (the common convention). Warnings could be routed through
// a logger if one existed; for now we synthesize silently.
foreach (var kvp in _models)
{
var uri = kvp.Key;
var xmlNs = !string.IsNullOrEmpty(kvp.Value.XmlSchemaUri)
? kvp.Value.XmlSchemaUri!
: uri.TrimEnd('/') + "/Types.xsd";
_modelXmlNamespace[uri] = xmlNs;
_xmlNamespaceToModel[xmlNs] = uri;
}

// HasEncoding references: source is DataType, target is encoding Object node with
// BrowseName "Default XML" / "Default JSON" / "Default Binary".
foreach (var node in _sequence)
{
if (node is UADataType dt && node.NodeId != null)
{
var bn = StripNamespacePrefix(node.BrowseName);
if (!string.IsNullOrEmpty(bn) && !_dataTypeBrowseNameIndex.ContainsKey(bn!))
_dataTypeBrowseNameIndex[bn!] = node.NodeId;
}

if (!_forwardRefs.TryGetValue(node.NodeId ?? "", out var fwds)) continue;
foreach (var entry in fwds)
{
if (entry.ReferenceTypeId != HasEncodingRefId) continue;
if (!_nodes.TryGetValue(entry.TargetNodeId, out var encodingNode)) continue;
var encBn = StripNamespacePrefix(encodingNode.BrowseName);
if (encBn == null) continue;

_encodingToDataType[entry.TargetNodeId] = node.NodeId!;
if (string.Equals(encBn, DefaultXmlBrowseName, StringComparison.Ordinal))
_dataTypeToXmlEncoding[node.NodeId!] = entry.TargetNodeId;
// Default JSON / Default Binary recorded in _encodingToDataType so reader-side
// normalization still works, but only XML needs the reverse map.
}
Comment on lines +74 to +86
}
}

private static string? StripNamespacePrefix(string? value)
{
if (string.IsNullOrEmpty(value)) return value;
if (!value.StartsWith("nsu=", StringComparison.Ordinal)) return value;
var semi = value.IndexOf(';');
return semi > 0 ? value.Substring(semi + 1) : value;
}

private void EnsureVariantIndexes()
{
if (_encodingToDataType == null) BuildVariantIndexes();
}

/// <summary>
/// If <paramref name="nodeId"/> is an encoding NodeId (Default XML/JSON/Binary), returns
/// the underlying DataType NodeId. Otherwise returns <paramref name="nodeId"/> unchanged
/// (or null when input is null).
/// </summary>
public string? NormalizeEncodingNodeIdToDataType(string? nodeId)
{
if (nodeId == null) return null;
EnsureVariantIndexes();
if (_nodes.TryGetValue(nodeId, out var node) && node is UADataType) return nodeId;
if (_encodingToDataType!.TryGetValue(nodeId, out var dt)) return dt;
return nodeId;
}

internal string? TryGetXmlEncodingNodeId(string dataTypeNodeId)
{
EnsureVariantIndexes();
return _dataTypeToXmlEncoding!.TryGetValue(dataTypeNodeId, out var enc) ? enc : null;
}

internal string? TryGetXmlSchemaUriForNodeId(string dataTypeNodeId)
{
EnsureVariantIndexes();
var ns = ExtractNsu(dataTypeNodeId);
if (ns == null) return null;
return _modelXmlNamespace!.TryGetValue(ns, out var xmlNs) ? xmlNs : null;
}

internal string? TryGetXmlSchemaUriForBrowseName(string browseName)
{
EnsureVariantIndexes();
if (!_dataTypeBrowseNameIndex!.TryGetValue(browseName, out var nodeId)) return null;
return TryGetXmlSchemaUriForNodeId(nodeId);
}

internal string? TryGetDataTypeBrowseName(string dataTypeNodeId)
{
if (!_nodes.TryGetValue(dataTypeNodeId, out var node)) return null;
return StripNamespacePrefix(node.BrowseName);
}

internal UADataType? ResolveDataType(string? typeId)
{
if (typeId == null) return null;
EnsureVariantIndexes();
if (_nodes.TryGetValue(typeId, out var n) && n is UADataType dt) return dt;
if (_encodingToDataType!.TryGetValue(typeId, out var real)
&& _nodes.TryGetValue(real, out var n2) && n2 is UADataType dt2) return dt2;
return null;
}

/// <summary>
/// Remove "Default JSON" encoding objects (and the <c>HasEncoding</c> references pointing at
/// them) from the AddressSpace. The JSON encoding of a Variant always uses the DataType
/// NodeId directly, so these nodes carry no information.
/// </summary>
public int StripDefaultJsonEncodings()
{
var toRemove = new List<string>();
foreach (var node in _sequence)
{
if (node.NodeClass != NodeClass.UAObject) continue;
var bn = StripNamespacePrefix(node.BrowseName);
if (!string.Equals(bn, DefaultJsonBrowseName, StringComparison.Ordinal)) continue;
// Confirm it really is a HasEncoding target of a DataType.
if (node.NodeId != null &&
_inverseRefs.TryGetValue(node.NodeId, out var inv) &&
inv.Any(e => e.ReferenceTypeId == HasEncodingRefId))
{
toRemove.Add(node.NodeId);
}
}
Comment on lines +162 to +174
foreach (var id in toRemove) RemoveNode(id);
if (toRemove.Count > 0) BuildVariantIndexes();
return toRemove.Count;
}

/// <summary>
/// Canonicalize every <see cref="UAVariable"/>'s Variant DOM:
/// reorder struct <see cref="JObject"/> keys to match <see cref="DataTypeDefinition"/>
/// field order, normalize any XML encoding <c>TypeId</c>s encountered in ExtensionObjects
/// to their underlying DataType NodeIds, and validate unions. Idempotent.
/// </summary>
public void ResolveVariants()
{
BuildVariantIndexes();

foreach (var node in _sequence)
{
if (node is not UAVariable v) continue;
if (v.Value?.Value == null) continue;

var dtId = NormalizeEncodingNodeIdToDataType(v.DataType);
v.Value.Value = CanonicalizeVariantValue(v.Value.Value, dtId, isArray: v.ValueRank is > 0);
}
}

private object? CanonicalizeVariantValue(object? value, string? dataTypeNodeId, bool isArray)
{
// Scalars pass through unchanged.
if (value is null) return null;

// Array: canonicalize each item with the element DataType.
if (value is JArray arr)
{
for (int i = 0; i < arr.Count; i++)
{
arr[i] = CanonicalizeJsonToken(arr[i], dataTypeNodeId) ?? JValue.CreateNull();
}
return arr;
}
if (value is List<object?> list)
{
for (int i = 0; i < list.Count; i++)
{
if (list[i] is JToken tok)
list[i] = CanonicalizeJsonToken(tok, dataTypeNodeId);
else if (list[i] is ExtensionObject eoItem)
list[i] = CanonicalizeExtensionObject(eoItem);
}
return list;
}

// ExtensionObject wrapper (from fresh XML→JSON conversion).
if (value is ExtensionObject eo)
{
return CanonicalizeExtensionObject(eo);
}

// Structure JObject (could be an ExtensionObject wrapper in JObject form, or a bare body).
if (value is JObject jo)
{
return CanonicalizeJsonToken(jo, dataTypeNodeId);
}

return value;
}

private ExtensionObject CanonicalizeExtensionObject(ExtensionObject eo)
{
// Normalize XML-encoding TypeId → DataType NodeId.
var canonicalTypeId = NormalizeEncodingNodeIdToDataType(eo.TypeId);
eo.TypeId = canonicalTypeId;
if (eo.Body is JObject jo)
{
eo.Body = CanonicalizeStructureBody(jo, canonicalTypeId);
}
return eo;
}

private JToken? CanonicalizeJsonToken(JToken token, string? dataTypeNodeId)
{
if (token == null || token.Type == JTokenType.Null) return token;
if (token is JArray arr)
{
for (int i = 0; i < arr.Count; i++)
arr[i] = CanonicalizeJsonToken(arr[i], dataTypeNodeId) ?? JValue.CreateNull();
return arr;
}
if (token is JObject jo)
{
// ExtensionObject JSON form?
if (jo["TypeId"] != null && jo["Body"] is JObject bodyJo)
{
var canonTid = NormalizeEncodingNodeIdToDataType(jo["TypeId"]!.ToString());
jo["TypeId"] = canonTid;
jo["Body"] = CanonicalizeStructureBody(bodyJo, canonTid);
return jo;
}
return CanonicalizeStructureBody(jo, dataTypeNodeId);
}
return token;
}

private JObject CanonicalizeStructureBody(JObject body, string? dataTypeNodeId)
{
var dt = ResolveDataType(dataTypeNodeId);
if (dt?.Definition?.Fields == null || dt.Definition.Fields.Count == 0)
{
// No schema — leave DOM untouched.
return body;
}

// Collect inherited + own fields in base-to-derived order.
var orderedFields = CollectAllFields(dt);

var canon = new JObject();
// Preserve sidecars for round-tripping without schema.
var typeNameSidecar = body["$typeName"]?.ToString()
?? StripNamespacePrefix(dt.BrowseName);
if (typeNameSidecar != null) canon["$typeName"] = typeNameSidecar;
if (body["$typeNs"] != null) canon["$typeNs"] = body["$typeNs"];

// Unions: preserve SwitchField first, then the one selected field.
// Detect via either the explicit Definition.IsUnion flag (set by generators that
// emit proper NodeSet2 XML) or the precomputed DataTypeForm which is populated for
// any subtype of the Union base type (i=12756) by ComputeDataTypeForms().
if (dt.Definition.IsUnion == true || dt.DataTypeForm == "Union")
{
var sf = body["SwitchField"];
if (sf != null) canon["SwitchField"] = sf;
// Copy whichever union arm key is present.
foreach (var field in orderedFields)
{
if (field.Name == null) continue;
if (body[field.Name] != null)
{
canon[field.Name] = CanonicalizeField(body[field.Name]!, field);
break;
}
}
return canon;
}

foreach (var field in orderedFields)
{
if (field.Name == null) continue;
if (body[field.Name] == null) continue;
canon[field.Name] = CanonicalizeField(body[field.Name]!, field);
}

return canon;
}

private JToken CanonicalizeField(JToken value, DataTypeField field)
{
// Arrays: apply element-wise using the field's DataType.
if (value is JArray arr)
{
var outArr = new JArray();
foreach (var item in arr)
{
outArr.Add(CanonicalizeJsonToken(item, field.DataType) ?? JValue.CreateNull());
}
return outArr;
}
if (value is JObject jo)
{
return CanonicalizeJsonToken(jo, field.DataType) ?? jo;
}
return value;
}

/// <summary>
/// Walks the UADataType inheritance chain and concatenates inherited fields before own fields.
/// Stops at the first ancestor without a Definition (e.g. the Structure base type).
/// </summary>
private List<DataTypeField> CollectAllFields(UADataType dt)
{
var chain = new List<UADataType> { dt };
EnsureSupertypeCache();
var current = dt.NodeId;
while (current != null && _supertypeCache!.TryGetValue(current, out var parent) && parent != null)
{
if (!_nodes.TryGetValue(parent, out var parentNode)) break;
if (parentNode is not UADataType parentDt) break;
if (parentDt.Definition?.Fields == null || parentDt.Definition.Fields.Count == 0) break;
chain.Add(parentDt);
current = parent;
}
chain.Reverse(); // base → derived
var ordered = new List<DataTypeField>();
foreach (var t in chain)
{
if (t.Definition?.Fields != null)
ordered.AddRange(t.Definition.Fields);
}
return ordered;
}
}
Loading
Loading