feat(opcua): HistoryReadEvents projects EventType, ConditionName, SourceNode; honours the where-clause (lmxopcua#746)
HistoryReadEvents projected only EventId / SourceName / Time / ReceiveTime / Message / Severity: a select clause asking for EventType, ConditionName or SourceNode (what most generic alarm viewers ask for) got nulls, and the event filter's where-clause (an OfType) was ignored. - HistoricalEvent gains optional EventTypeName, ConditionName and AlarmId. SqlServer, QuestDB and Gateway event reads fill them from what their alarm writers already store (AlarmTypeName / AlarmName / AlarmId; the gateway event Type and its AlarmName / AlarmId properties). - The node manager maps the type name to its Part 9 event type (unknown or non-event => AlarmConditionType), projects ConditionName, and resolves SourceNode to the materialised condition the alarm id names (the live events' SourceNode), else null. - The where-clause is evaluated per event with the SDK content filter through an IFilterTarget (OfType via the type tree; other operators against the projected fields), after the page's cursor is taken, so paging is unchanged. Tested in the node manager, over a real client session (the Client.CLI has no `historyread --events` mode yet; filed as a follow-up), and by alarm writer -> event read round trips on SqlServer and QuestDB. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -118,13 +118,26 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
|
||||
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
|
||||
/// <param name="Message">Human-readable message text.</param>
|
||||
/// <param name="Severity">OPC UA severity (1-1000). Backends map their native priority scale onto this range.</param>
|
||||
/// <param name="EventTypeName">
|
||||
/// The stored alarm type name (<c>AlarmTypeName</c> — e.g. <c>AlarmCondition</c>, <c>LimitAlarm</c>,
|
||||
/// <c>OffNormalAlarm</c>, <c>DiscreteAlarm</c>), or null when the backend has none. The server maps it to the OPC UA
|
||||
/// event type NodeId it projects as <c>EventType</c> and filters an <c>OfType</c> where-clause by.
|
||||
/// </param>
|
||||
/// <param name="ConditionName">The condition's name (the stored <c>AlarmName</c>), projected as <c>ConditionName</c>.</param>
|
||||
/// <param name="AlarmId">
|
||||
/// The stored alarm id (a native alarm's RawPath / a scripted alarm's id) — what the server resolves the event's
|
||||
/// <c>SourceNode</c> (the condition node) from, when that condition is materialised.
|
||||
/// </param>
|
||||
public sealed record HistoricalEvent(
|
||||
string EventId,
|
||||
string? SourceName,
|
||||
DateTime EventTimeUtc,
|
||||
DateTime ReceivedTimeUtc,
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
ushort Severity,
|
||||
string? EventTypeName = null,
|
||||
string? ConditionName = null,
|
||||
string? AlarmId = null);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read.</param>
|
||||
|
||||
@@ -33,9 +33,16 @@ internal static class EventMapper
|
||||
EventTimeUtc: historianEvent.EventTime?.ToDateTime() ?? default, // Utc kind
|
||||
ReceivedTimeUtc: historianEvent.ReceivedTime?.ToDateTime() ?? default, // Utc kind
|
||||
Message: message,
|
||||
Severity: ParseSeverity(historianEvent.Properties));
|
||||
Severity: ParseSeverity(historianEvent.Properties),
|
||||
// What GatewayAlarmHistorianWriter sent (AlarmEventMapper): Type = AlarmTypeName, AlarmName / AlarmId properties.
|
||||
EventTypeName: string.IsNullOrEmpty(historianEvent.Type) ? null : historianEvent.Type,
|
||||
ConditionName: Property(historianEvent.Properties, "AlarmName"),
|
||||
AlarmId: Property(historianEvent.Properties, "AlarmId"));
|
||||
}
|
||||
|
||||
private static string? Property(IDictionary<string, string> properties, string name) =>
|
||||
properties.TryGetValue(name, out var v) && !string.IsNullOrEmpty(v) ? v : null;
|
||||
|
||||
/// <summary>Maps a batch of gateway events to historical events, in order.</summary>
|
||||
/// <param name="events">The gateway wire events.</param>
|
||||
/// <returns>The driver-agnostic historical events.</returns>
|
||||
|
||||
+5
-2
@@ -449,7 +449,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
var (lo, hi) = start <= end ? (start, end) : (end, start);
|
||||
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
|
||||
var sql =
|
||||
$"SELECT event_id, source, ts, received_utc, message, severity FROM {_conn.EventTable} " +
|
||||
$"SELECT event_id, source, ts, received_utc, message, severity, alarm_type_name, alarm_name, alarm_id FROM {_conn.EventTable} " +
|
||||
(reverse ? "WHERE ts > $1 AND ts <= $2" : "WHERE ts >= $1 AND ts < $2") +
|
||||
(sourceName is null ? "" : " AND source = $3") +
|
||||
(reverse ? " ORDER BY ts DESC, event_id DESC" : " ORDER BY ts ASC, event_id ASC") +
|
||||
@@ -474,7 +474,10 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
eventTime,
|
||||
reader.IsDBNull(3) ? eventTime : QuestDbConnection.ReadUtc(reader, 3),
|
||||
reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
HistoricalEventRows.ClampSeverity(reader.IsDBNull(5) ? 0 : reader.GetInt32(5)));
|
||||
HistoricalEventRows.ClampSeverity(reader.IsDBNull(5) ? 0 : reader.GetInt32(5)),
|
||||
EventTypeName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(6) ? null : reader.GetString(6)),
|
||||
ConditionName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(7) ? null : reader.GetString(7)),
|
||||
AlarmId: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(8) ? null : reader.GetString(8)));
|
||||
if (bounded && events.Count == maxEvents)
|
||||
{
|
||||
continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
|
||||
|
||||
@@ -7,4 +7,9 @@ public static class HistoricalEventRows
|
||||
/// <param name="severity">Stored severity.</param>
|
||||
/// <returns>The severity clamped to 0..65535.</returns>
|
||||
public static ushort ClampSeverity(int severity) => (ushort)Math.Clamp(severity, 0, ushort.MaxValue);
|
||||
|
||||
/// <summary>A stored optional text column as the event carries it: empty (the writers' "none") is null.</summary>
|
||||
/// <param name="value">The stored text.</param>
|
||||
/// <returns>The text, or null when empty or absent.</returns>
|
||||
public static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
|
||||
+5
-2
@@ -396,7 +396,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
||||
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
|
||||
var sql =
|
||||
$"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity " +
|
||||
$"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity, AlarmTypeName, AlarmName, AlarmId " +
|
||||
$"FROM {_conn.Q("Event")} WHERE " +
|
||||
(reverse ? "EventTimeUtc > @lo AND EventTimeUtc <= @hi" : "EventTimeUtc >= @lo AND EventTimeUtc < @hi") +
|
||||
(sourceName is null ? "" : " AND SourceName = @source") +
|
||||
@@ -421,7 +421,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
SqlHistorianConnection.GetUtc(reader, 2),
|
||||
SqlHistorianConnection.GetUtc(reader, 3),
|
||||
reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
HistoricalEventRows.ClampSeverity(reader.GetInt32(5)));
|
||||
HistoricalEventRows.ClampSeverity(reader.GetInt32(5)),
|
||||
EventTypeName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(6) ? null : reader.GetString(6)),
|
||||
ConditionName: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(7) ? null : reader.GetString(7)),
|
||||
AlarmId: HistoricalEventRows.NullIfEmpty(reader.IsDBNull(8) ? null : reader.GetString(8)));
|
||||
if (bounded && events.Count == maxEvents)
|
||||
{
|
||||
continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
|
||||
|
||||
@@ -5714,8 +5714,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
// Snapshot the select clauses once — the same filter projects every node's events.
|
||||
// Snapshot the select clauses once — the same filter projects every node's events — and the where-clause, which
|
||||
// is evaluated per event against the same projection (an OfType through the server's type tree).
|
||||
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
|
||||
var where = details.Filter?.WhereClause is { Elements.Count: > 0 } w ? w : null;
|
||||
var filterContext = where is null ? null : new FilterContext(Server.NamespaceUris, Server.TypeTree, context.PreferredLocales, Server.Telemetry);
|
||||
var session = context.OperationContext?.Session;
|
||||
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
@@ -5739,7 +5742,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
|
||||
var sn = sourceName;
|
||||
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, nodesToRead, session, results, errors, ct));
|
||||
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, where, filterContext, nodesToRead, session,
|
||||
results, errors, ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
@@ -5757,6 +5761,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="sourceName">The registered historian event-source name for this notifier.</param>
|
||||
/// <param name="details">The event-read details (window + count cap).</param>
|
||||
/// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
|
||||
/// <param name="where">The event filter's where-clause, or null for none.</param>
|
||||
/// <param name="filterContext">The context the where-clause is evaluated in (null when there is none).</param>
|
||||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
||||
/// <param name="ct">Per-request deadline token.</param>
|
||||
@@ -5765,6 +5771,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
string? sourceName,
|
||||
ReadEventDetails details,
|
||||
SimpleAttributeOperandCollection selectClauses,
|
||||
ContentFilter? where,
|
||||
FilterContext? filterContext,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
ISession? session,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
@@ -5830,7 +5838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
results[handle.Index] = new SdkHistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.GoodNoData,
|
||||
HistoryData = new ExtensionObject(ProjectEvents([], selectClauses)),
|
||||
HistoryData = new ExtensionObject(ProjectEvents([], selectClauses, null, null)),
|
||||
};
|
||||
errors[handle.Index] = ServiceResult.Good;
|
||||
return;
|
||||
@@ -5961,12 +5969,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
EmptyToTicks: walkHint.EmptyToTicks);
|
||||
}
|
||||
|
||||
var historyEvent = ProjectEvents(events, selectClauses);
|
||||
// The where-clause filters AFTER the page's cursor was taken from the unfiltered events, so paging (time,
|
||||
// boundary skip) is exactly as without a filter: a page may come back with fewer events — even none — and a
|
||||
// continuation point, never skipping or repeating one.
|
||||
var historyEvent = ProjectEvents(events, selectClauses, where, filterContext);
|
||||
var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
|
||||
results[handle.Index] = new SdkHistoryReadResult
|
||||
{
|
||||
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
|
||||
StatusCode = events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||||
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events — or none matched).
|
||||
StatusCode = historyEvent.Events.Count == 0 && outboundCp is null ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyEvent),
|
||||
// Null when the page was complete, or when no session is available to hold the cursor.
|
||||
ContinuationPoint = outboundCp,
|
||||
@@ -6415,23 +6426,28 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
/// <summary>
|
||||
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
|
||||
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
|
||||
/// <paramref name="selectClauses"/>' fields in select-clause order (see
|
||||
/// <see cref="ProjectEventField"/>).
|
||||
/// one <see cref="HistoryEventFieldList"/> per event that passes <paramref name="where"/>, each carrying the
|
||||
/// requested <paramref name="selectClauses"/>' fields in select-clause order (see <see cref="ProjectEventField"/>).
|
||||
/// </summary>
|
||||
/// <param name="events">The historian's event rows.</param>
|
||||
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
|
||||
/// <param name="where">The request's where-clause, or null for none (every event passes).</param>
|
||||
/// <param name="filterContext">The context <paramref name="where"/> is evaluated in.</param>
|
||||
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
|
||||
private static HistoryEvent ProjectEvents(
|
||||
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
|
||||
private HistoryEvent ProjectEvents(
|
||||
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses,
|
||||
ContentFilter? where, FilterContext? filterContext)
|
||||
{
|
||||
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var target = new HistoricalEventFilterTarget(this, evt);
|
||||
if (where is not null && filterContext is not null && !where.Evaluate(filterContext, target)) continue;
|
||||
|
||||
var fields = new VariantCollection(selectClauses.Count);
|
||||
foreach (var operand in selectClauses)
|
||||
{
|
||||
fields.Add(ProjectEventField(evt, operand));
|
||||
fields.Add(target.Project(operand));
|
||||
}
|
||||
|
||||
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
||||
@@ -6440,32 +6456,98 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return new HistoryEvent { Events = fieldLists };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A historical event as the SDK's content-filter evaluator sees it: its type (for <c>OfType</c>, through the
|
||||
/// server's type tree) and its projected fields (for every other operator).
|
||||
/// </summary>
|
||||
private sealed class HistoricalEventFilterTarget(OtOpcUaNodeManager owner, HistoricalEvent evt) : IFilterTarget
|
||||
{
|
||||
private NodeId? _type;
|
||||
|
||||
/// <summary>The event's OPC UA event type (see <see cref="OtOpcUaNodeManager.EventTypeOf"/>).</summary>
|
||||
public NodeId EventType => _type ??= owner.EventTypeOf(evt.EventTypeName);
|
||||
|
||||
public Variant Project(SimpleAttributeOperand operand) => owner.ProjectEventField(evt, operand, EventType);
|
||||
|
||||
public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) =>
|
||||
EventType == typeDefinitionId || context.TypeTree.IsTypeOf(EventType, typeDefinitionId);
|
||||
|
||||
public object? GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, IList<QualifiedName> relativePath,
|
||||
uint attributeId, NumericRange indexRange)
|
||||
{
|
||||
if (attributeId != Attributes.Value || relativePath is null || relativePath.Count == 0) return null;
|
||||
var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId };
|
||||
foreach (var name in relativePath) operand.BrowsePath.Add(name);
|
||||
return Project(operand).Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA event type of a historical alarm event, from its stored alarm type name: the Part 9 type of that name
|
||||
/// (<c>LimitAlarm</c> ⇒ <c>LimitAlarmType</c>, <c>OffNormalAlarm</c> ⇒ <c>OffNormalAlarmType</c>, …, with or without the
|
||||
/// <c>Type</c> suffix) when it is an event type in the server's type tree; otherwise — no name, or a name that is not
|
||||
/// an event type — <c>AlarmConditionType</c>: every historical row is alarm history, of a subtype not modelled.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The stored alarm type name, or null.</param>
|
||||
/// <returns>The event type NodeId.</returns>
|
||||
private NodeId EventTypeOf(string? typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName)) return ObjectTypeIds.AlarmConditionType;
|
||||
return s_eventTypesByName.GetOrAdd(typeName, name =>
|
||||
{
|
||||
var field = typeof(ObjectTypeIds).GetField(name.EndsWith("Type", StringComparison.Ordinal) ? name : name + "Type",
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
||||
return field?.GetValue(null) is NodeId type && Server.TypeTree.IsTypeOf(type, ObjectTypeIds.BaseEventType)
|
||||
? type
|
||||
: ObjectTypeIds.AlarmConditionType;
|
||||
});
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, NodeId> s_eventTypesByName = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>SourceNode</c> of a historical alarm event: the materialised condition its alarm id names (a native
|
||||
/// condition's id is its RawPath, Raw realm; a scripted one its alarm id, UNS realm) — the SourceNode the live
|
||||
/// condition events carry. Null when no such condition is materialised now (removed, or never on this node).
|
||||
/// </summary>
|
||||
private NodeId? SourceNodeOf(HistoricalEvent evt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(evt.AlarmId)) return null;
|
||||
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
|
||||
if (_alarmConditions.TryGetValue(MapKey(realm, evt.AlarmId), out var condition)) return condition.NodeId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
|
||||
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
|
||||
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
|
||||
/// (case-sensitive, per the OPC UA BaseEventType / ConditionType field names) against the
|
||||
/// <see cref="HistoricalEvent"/> shape:
|
||||
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
|
||||
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
|
||||
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
|
||||
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
|
||||
/// <c>EventId</c>→ByteString, <c>EventType</c>→NodeId (<paramref name="eventType"/>), <c>SourceName</c>→String,
|
||||
/// <c>SourceNode</c>→NodeId (the resolved condition, else null), <c>Time</c>/<c>ReceiveTime</c>→DateTime,
|
||||
/// <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16, <c>ConditionName</c>→String. Any other leaf and an empty
|
||||
/// BrowsePath ⇒ <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
|
||||
/// </summary>
|
||||
/// <param name="evt">The source event row.</param>
|
||||
/// <param name="operand">The select-clause operand naming the field to project.</param>
|
||||
/// <param name="eventType">The event's OPC UA event type (<see cref="EventTypeOf"/>).</param>
|
||||
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
|
||||
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
|
||||
private Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand, NodeId eventType)
|
||||
{
|
||||
var leaf = LeafFieldName(operand);
|
||||
return leaf switch
|
||||
{
|
||||
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
|
||||
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
|
||||
"EventType" => new Variant(eventType),
|
||||
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
|
||||
"SourceNode" => SourceNodeOf(evt) is { } source ? new Variant(source) : Variant.Null,
|
||||
"Time" => new Variant(evt.EventTimeUtc),
|
||||
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
|
||||
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
|
||||
"Severity" => new Variant(evt.Severity), // UInt16
|
||||
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
|
||||
"ConditionName" => evt.ConditionName is null ? Variant.Null : new Variant(evt.ConditionName),
|
||||
// empty path / unrecognised leaf ⇒ null (spec-conformant).
|
||||
_ => Variant.Null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user