merge: SQL contracts review findings resolved (Driver.Sql.Contracts-001 scalar keyValue, -002 fleet-wide numeric-enum rejection)
This commit is contained in:
@@ -8,10 +8,13 @@ public enum JsonEnumRead
|
||||
/// <summary>The property is absent, or present but not a JSON string (nothing to validate).</summary>
|
||||
Absent,
|
||||
|
||||
/// <summary>The property is a JSON string that parses (case-insensitively) to the enum.</summary>
|
||||
/// <summary>The property is a JSON string naming (case-insensitively) a declared enum member.</summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>The property is a JSON string that does NOT parse to the enum (a typo'd value).</summary>
|
||||
/// <summary>
|
||||
/// The property is a JSON string that names no declared enum member — a typo, or a numeric
|
||||
/// spelling such as <c>"8"</c>, which is not a name at all.
|
||||
/// </summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
@@ -67,6 +70,19 @@ public static class TagConfigJson
|
||||
/// treatment of an absent or present-but-non-string property — so a caller that extracts "the
|
||||
/// string value, or null" gets the runtime's leniency for free.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only a member NAME is valid — a numeric spelling is refused before the parse.</b>
|
||||
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"1"</c> for any enum
|
||||
/// and hands back whatever member sits at that ordinal, and an out-of-range <c>"99"</c> parses
|
||||
/// just as readily — so a TagConfig carrying a numerically-serialised enum (the repo-wide
|
||||
/// authoring trap) read <see cref="JsonEnumRead.Valid"/> at all six driver parsers and bound a
|
||||
/// tag to a type nobody named, under <c>Good</c> quality. The same idiom guards
|
||||
/// <c>RawBrowseCommitMapper.NativeType</c> and <c>MTConnectTagDefinitionFactory</c>'s own type
|
||||
/// parse. <see cref="Enum.IsDefined{TEnum}(TEnum)"/> then covers what the leading-character
|
||||
/// check cannot: <c>TryParse</c> also bitwise-ORs a comma-separated list for a
|
||||
/// non-<c>[Flags]</c> enum, so <c>"Beta, Gamma"</c> would otherwise parse to a value no member
|
||||
/// declares.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
|
||||
/// <param name="text">The authored value's text, or <see langword="null"/> when there is none.</param>
|
||||
@@ -80,12 +96,26 @@ public static class TagConfigJson
|
||||
value = default;
|
||||
return JsonEnumRead.Absent;
|
||||
}
|
||||
if (Enum.TryParse(text, ignoreCase: true, out value))
|
||||
if (IsMemberNameSpelling(text) && Enum.TryParse(text, ignoreCase: true, out value) && Enum.IsDefined(value))
|
||||
return JsonEnumRead.Valid;
|
||||
value = default;
|
||||
return JsonEnumRead.Invalid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="text"/> is spelled as a member name rather than as a number. Judged on
|
||||
/// the TRIMMED text because <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> trims
|
||||
/// before parsing, so <c>" 8 "</c> is the numeric spelling too; a C# member name can never begin
|
||||
/// with a digit or a sign, so no valid name is lost.
|
||||
/// </summary>
|
||||
/// <param name="text">The authored value's text.</param>
|
||||
/// <returns><see langword="true"/> when the text could be a member name.</returns>
|
||||
private static bool IsMemberNameSpelling(string text)
|
||||
{
|
||||
var span = text.AsSpan().Trim();
|
||||
return span.Length > 0 && !char.IsAsciiDigit(span[0]) && span[0] is not ('-' or '+');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lenient enum read with today's exact semantics: absent OR invalid ⇒ <paramref name="fallback"/>;
|
||||
/// only a present, parseable string yields the parsed value. Bit-for-bit equivalent to the six
|
||||
|
||||
@@ -62,7 +62,7 @@ public static class SqlEquipmentTagParser
|
||||
case SqlTagModel.KeyValue:
|
||||
{
|
||||
var keyColumn = ReadString(root, "keyColumn");
|
||||
var keyValue = ReadString(root, "keyValue");
|
||||
var keyValue = ReadScalar(root, "keyValue");
|
||||
var valueColumn = ReadString(root, "valueColumn");
|
||||
if (string.IsNullOrWhiteSpace(keyColumn)
|
||||
|| string.IsNullOrWhiteSpace(valueColumn)
|
||||
@@ -115,8 +115,17 @@ public static class SqlEquipmentTagParser
|
||||
|
||||
/// <summary>
|
||||
/// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
|
||||
/// boolean yields its raw token — so an authored <c>whereValue</c> of either shape is captured
|
||||
/// verbatim for later parameter binding. Null when absent or not a scalar.
|
||||
/// boolean yields its raw token — so an authored value of any of those shapes is captured verbatim
|
||||
/// for later parameter binding. Null when absent or not a scalar.
|
||||
/// <para>
|
||||
/// <b>Both value-bearing fields go through here</b>, <c>keyValue</c> as well as
|
||||
/// <c>whereValue</c>, because there is no downstream difference between them: the reader BINDS
|
||||
/// each as a <see cref="System.Data.Common.DbParameter"/> and matches each back as text —
|
||||
/// <c>SqlPollReader.IndexByKey</c> stringifies the key cell precisely so an authored key can
|
||||
/// face an INTEGER key column. Reading one of them string-only refused a tag the other shape
|
||||
/// of the same authoring mistake was served under, and the deploy gate then reported the field
|
||||
/// as MISSING when it was right there in the blob.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static string? ReadScalar(JsonElement o, string name)
|
||||
{
|
||||
|
||||
@@ -129,8 +129,13 @@ public static class SqlTagDefinitionFactory
|
||||
warnings.Add("Sql KeyValue tag has no 'keyColumn'.");
|
||||
if (string.IsNullOrWhiteSpace(ReadString(root, "valueColumn")))
|
||||
warnings.Add("Sql KeyValue tag has no 'valueColumn'.");
|
||||
if (ReadString(root, "keyValue") is null)
|
||||
warnings.Add("Sql KeyValue tag has no 'keyValue' (an empty string is allowed; an absent key is not).");
|
||||
if (!HasScalar(root, "keyValue"))
|
||||
{
|
||||
warnings.Add(
|
||||
"Sql KeyValue tag has no usable 'keyValue' — it must be a string, number or "
|
||||
+ "boolean (an empty string is allowed; an absent key, or an object/array, is not).");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SqlTagModel.WideRow:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user