Make Variant equality reflexive for typed null arrays

A typed null array or matrix (e.g. an unset Int32[] or Variant[] structure
field) equalled Variant.Null and hashed like it, but did not equal an
identical typed null or even itself: the array accessors cannot read an
absent payload. Two such variants of the same (or a convertible) type now
compare equal, so equality agrees with GetHashCode and Structure.IsEqual
holds for identical default structures (OPC 10000-6 5.1.11). Typed nulls of
unrelated types stay unequal.

The codec regression tests compare XmlParser and XmlDecoder fields with
plain equality again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-02 07:38:38 +02:00
parent 73def404c9
commit b30e1cdd00
3 changed files with 96 additions and 8 deletions
+19 -2
View File
@@ -1146,7 +1146,7 @@ namespace Opc.Ua
{
return 0;
}
if (!ValueIsValueType && ValueIsDefaultOrNull)
if (IsAbsentPayload)
{
// A typed null payload (null array, matrix, byte string,
// qualified name, ...) equals Variant.Null and must
@@ -7906,7 +7906,7 @@ namespace Opc.Ua
// letting it compare equal collapses both into one bucket of
// every Dictionary and HashSet keyed on Variant.
Variant typed = IsNull ? other : this;
return !typed.ValueIsValueType && typed.ValueIsDefaultOrNull;
return typed.IsAbsentPayload;
}
TypeInfo ourTypeInfo = m_typeInfo;
@@ -7918,6 +7918,15 @@ namespace Opc.Ua
{
return false;
}
if (IsAbsentPayload && other.IsAbsentPayload)
{
// Both hold the typed null of the same (or a convertible) type,
// e.g. a null array field. Each already equals Variant.Null and
// hashes like it, so they must equal each other as well; the
// accessors below cannot read an absent payload and made such a
// variant unequal even to itself.
return true;
}
if (ourTypeInfo.ValueRank == 0 || otherTypeInfo.ValueRank == 0)
{
// A null MatrixOf carries no dimensions, so its type info
@@ -8413,6 +8422,14 @@ namespace Opc.Ua
}
}
/// <summary>
/// True for a typed variant whose reference payload is absent (a null
/// array, matrix, string, byte string, ...). Such a variant equals
/// <see cref="Null"/> and hashes like it. A numeric, boolean or status
/// code zero is a value, not an absent one.
/// </summary>
private bool IsAbsentPayload => !ValueIsValueType && ValueIsDefaultOrNull;
/// <summary>
/// Returns true if the variant holds an array or matrix without
/// elements. A null array counts as empty because OPC 10000-6
@@ -629,6 +629,82 @@ namespace Opc.Ua.Types.Tests.BuiltIn
});
}
[Test]
public void VariantWithTypedNullArrayEqualsAnIdenticalTypedNull()
{
// A typed null array equalled Variant.Null but not itself: the
// array accessors cannot read an absent payload. Equality must be
// reflexive and agree with the hash code (OPC 10000-6 5.1.11).
TypeInfo[] types =
[
TypeInfo.Arrays.Int32,
TypeInfo.Arrays.String,
TypeInfo.Arrays.Variant,
TypeInfo.Arrays.ExtensionObject,
TypeInfo.Create(BuiltInType.Double, ValueRanks.TwoDimensions)
];
Assert.Multiple(() =>
{
foreach (TypeInfo type in types)
{
Variant a = Variant.CreateDefault(type);
Variant b = Variant.CreateDefault(type);
Assert.That(IsEqual(a, a), Is.True, type.ToString());
Assert.That(IsEqual(a, b), Is.True, type.ToString());
Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode()), type.ToString());
}
// the boxed null ArrayOf spelling equals the payload-less one
Variant nullInt32s = Variant.CreateDefault(TypeInfo.Arrays.Int32);
Assert.That(IsEqual(Variant.From(ArrayOf<int>.Null), nullInt32s), Is.True);
// a typed null still differs from a value and from another type
Assert.That(IsEqual(nullInt32s, Variant.From(s_oneTwoThree.ToArrayOf())), Is.False);
Variant nullBooleans = Variant.CreateDefault(TypeInfo.Arrays.Boolean);
Variant nullStrings = Variant.CreateDefault(TypeInfo.Arrays.String);
Assert.That(IsEqual(nullBooleans, nullStrings), Is.False);
});
}
[Test]
public void StructureWithNullArrayFieldsEqualsAnIdenticalStructure()
{
var definition = new StructureDefinition
{
BaseDataType = DataTypeIds.Structure,
StructureType = StructureType.Structure,
Fields =
[
new StructureField
{
Name = "A",
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.OneDimension
},
new StructureField
{
Name = "V",
DataType = DataTypeIds.BaseDataType,
ValueRank = ValueRanks.OneDimension
}
]
};
var types = new Dictionary<string, BuiltInType>
{
["A"] = BuiltInType.Int32,
["V"] = BuiltInType.Variant
};
var a = new global::Opc.Ua.Encoders.Structure(
new System.Xml.XmlQualifiedName("S", Namespaces.OpcUaXsd),
new ExpandedNodeId(1u), new ExpandedNodeId(2u), new ExpandedNodeId(3u),
definition,
types);
var b = (global::Opc.Ua.Encoders.Structure)a.Clone();
Assert.That(a.IsEqual(b), Is.True);
}
[Test]
public void LocalizedTextWithEmptyLocaleAndNullTextHashesLikeNull()
{
@@ -650,12 +650,7 @@ namespace Opc.Ua.Types.Tests.Encoders
foreach (string field in new[] { "N", "I", "U", "M" })
{
Assert.That(parsed[field].TypeInfo, Is.EqualTo(output[field].TypeInfo), field);
// Variant equality never matches a typed null array, so
// null or empty arrays are compared by their emptiness.
if (!output[field].IsEmptyArray || !parsed[field].IsEmptyArray)
{
Assert.That(parsed[field], Is.EqualTo(output[field]), field);
}
Assert.That(parsed[field], Is.EqualTo(output[field]), field);
}
break;
default: