merge: SQL contracts review findings resolved (Driver.Sql.Contracts-001 scalar keyValue, -002 fleet-wide numeric-enum rejection)

This commit is contained in:
Joseph Doherty
2026-08-10 08:10:36 -04:00
12 changed files with 332 additions and 34 deletions
@@ -1,6 +1,8 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using RuntimeJsonEnumRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.JsonEnumRead;
using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -415,13 +417,20 @@ public sealed class MqttTagConfigModel
/// <param name="name">The property name to read.</param>
/// <returns>The parsed enum value, or <c>null</c>.</returns>
private static TEnum? GetEnumNullable<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
=> TagConfigJson.GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : null;
=> RuntimeTagConfigJson.ClassifyEnumText<TEnum>(TagConfigJson.GetString(o, name), out var v)
== RuntimeJsonEnumRead.Valid
? v
: null;
/// <summary>
/// Describes a present-but-invalid enum field, or <c>null</c> when it is absent or valid.
/// Mirrors <c>TagConfigJson.TryReadEnum</c>'s absent/valid/invalid split exactly — including its
/// treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the
/// driver would happily default.
/// driver would happily default. The VERDICT is the runtime's own
/// <see cref="RuntimeTagConfigJson.ClassifyEnumText{TEnum}"/>, never a local
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/>: a numeric spelling such as
/// <c>"1"</c> parses there and names no member (Driver.Sql.Contracts-002), so restating the rule
/// here would accept a blob the driver refuses — the Gitea #549 direction.
/// </summary>
/// <typeparam name="TEnum">The enum type expected.</typeparam>
/// <param name="o">The TagConfig key bag.</param>
@@ -433,7 +442,7 @@ public sealed class MqttTagConfigModel
{
return null;
}
return Enum.TryParse<TEnum>(raw, ignoreCase: true, out _)
return RuntimeTagConfigJson.ClassifyEnumText<TEnum>(raw, out _) == RuntimeJsonEnumRead.Valid
? null
: $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames<TEnum>())}.";
}
@@ -1,6 +1,7 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -24,7 +25,9 @@ public sealed class SqlTagConfigModel
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
public string? KeyColumn { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).
/// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring
/// the parser's scalar read.</summary>
public string? KeyValue { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
@@ -71,7 +74,7 @@ public sealed class SqlTagConfigModel
Model = model ?? SqlTagModel.KeyValue,
Table = TagConfigJson.GetString(o, "table"),
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
KeyValue = TagConfigJson.GetString(o, "keyValue"),
KeyValue = TagConfigJson.GetScalarText(o, "keyValue"),
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
@@ -156,7 +159,9 @@ public sealed class SqlTagConfigModel
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it.
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it. The verdict is
// the RUNTIME's own ClassifyEnumText, not a local Enum.TryParse — restating it is how the two doors drift
// (Gitea #549), and a numeric spelling like "1" is exactly where they would have (Driver.Sql.Contracts-002).
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
where TEnum : struct, Enum
{
@@ -164,7 +169,9 @@ public sealed class SqlTagConfigModel
{
return (null, null);
}
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
return RuntimeTagConfigJson.ClassifyEnumText<TEnum>(s, out var parsed) == JsonEnumRead.Valid
? (parsed, null)
: (null, s);
}
}
@@ -199,7 +206,7 @@ public sealed class SqlRowSelectorModel
return new SqlRowSelectorModel
{
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
WhereValue = ReadScalarText(bag, "whereValue"),
WhereValue = TagConfigJson.GetScalarText(bag, "whereValue"),
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
_bag = bag,
};
@@ -216,12 +223,4 @@ public sealed class SqlRowSelectorModel
TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp);
return o.Count == 0 ? null : o;
}
// Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw
// token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar).
private static string? ReadScalarText(JsonObject o, string name)
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
}
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using JsonEnumRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.JsonEnumRead;
using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -33,6 +34,23 @@ public static class TagConfigJson
public static string? GetString(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
/// <summary>
/// Reads a scalar as its textual form — a JSON string yields its contents, a number or boolean
/// yields its raw token; <c>null</c> when absent, JSON <c>null</c>, or a non-scalar node. Mirrors
/// the runtime parsers' <c>ReadScalar</c>, for the value-bearing fields a driver BINDS as a
/// parameter and so may legitimately author as a number (a Sql <c>keyValue</c> facing an INTEGER
/// key column, say). Reading such a field with <see cref="GetString"/> makes the editor refuse a
/// blob the driver serves.
/// </summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The scalar's text, or <c>null</c> when absent or not a scalar.</returns>
public static string? GetScalarText(JsonObject o, string name)
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
}
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
@@ -57,14 +75,23 @@ public static class TagConfigJson
public static bool? GetBoolNullable(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : null;
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
/// <summary>
/// Reads an enum by its serialised NAME, or <paramref name="fallback"/> if absent/unreadable. The
/// name/number distinction comes from
/// <see cref="RuntimeTagConfigJson.ClassifyEnumText{TEnum}"/> rather than from
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> directly, so a numerically-spelled
/// value the runtime refuses is not DISPLAYED here as the member sitting at that ordinal — the
/// editor would show, and then save, a type nobody authored.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value to return if the property is absent or unparseable.</param>
/// <param name="fallback">The value to return if the property is absent or unreadable.</param>
/// <returns>The parsed enum value, or <paramref name="fallback"/>.</returns>
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
=> RuntimeTagConfigJson.ClassifyEnumText<TEnum>(GetString(o, name), out var v) == JsonEnumRead.Valid
? v
: fallback;
/// <summary>
/// Describes a present-but-invalid enum field the way the RUNTIME parser judges it, or