feat(drivers): Contracts-side TagConfig Inspect for Sql, MTConnect, Calculation and OpcUaClient
Deferment-register G-7: these four were validated by the AdminUI's TagConfigValidator at authoring but by nothing at deploy, so config arriving by CSV import or API met no gate at all. The deploy gate dispatches to a driver's *TagDefinitionFactory.Inspect in its Contracts assembly, and none of the four had one. Rather than mirror the AdminUI models (a mirror of an accept/reject boundary drifts silently, with the gate describing a shape the driver no longer reads), each Inspect is built on the driver's own runtime parse: - Sql wraps the existing SqlEquipmentTagParser, which already lived in Sql.Contracts. The P3-deferred Query model stays refused; Inspect surfaces the refusal rather than weakening it. - MTConnect's blob reader MOVED out of MTConnectDriver (it was private, with a private DTO) into MTConnectTagDefinitionFactory; the driver now calls it. Behaviour unchanged. - OpcUaClient's nodeId read MOVED off NamespaceMap into OpcUaClientTagDefinitionFactory; NamespaceMap now calls it. Behaviour unchanged. - Calculation gains a Contracts assembly it never had. Only the JSON reading moved there (CalculationTagDefinitionFactory.TryReadTagConfig); building a CalculationTagDefinition still needs EquipmentScriptPaths from Commons and stays in the driver, so the split is at the blob boundary. The new project is deliberately reference-free — a calc blob is strings, a bool and an int, with no Core.Abstractions type in it. Each Inspect runs the parse FIRST and returns empty on success, so "never stricter than the runtime" is structural rather than a promise: a warning is only reachable for a blob the driver would have refused and then skipped silently. Calculation additionally reports two present-but-silently-discarded fields (a non-boolean changeTriggered reads as true; a non-positive-integer timerIntervalMs is dropped), and deliberately does NOT warn about a trigger-less tag or a sub-50ms timer — the editor refuses both, the runtime honours both, and the deploy gate follows the runtime.
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// The four fields a calc tag's <c>TagConfig</c> blob carries, as
|
||||
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> read them. Deliberately not the
|
||||
/// driver's <c>CalculationTagDefinition</c>: that record additionally carries the tag's RawPath identity
|
||||
/// and the dependency refs extracted from the script source, neither of which is in the blob.
|
||||
/// </summary>
|
||||
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity that computes the tag; never blank.</param>
|
||||
/// <param name="ScriptSource">The resolved C# script source; empty when the blob carried none.</param>
|
||||
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes (default <see langword="true"/>).</param>
|
||||
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or <see langword="null"/> for change-trigger only.</param>
|
||||
public readonly record struct CalculationTagConfigFields(
|
||||
string ScriptId,
|
||||
string ScriptSource,
|
||||
bool ChangeTriggered,
|
||||
int? TimerIntervalMs);
|
||||
|
||||
/// <summary>
|
||||
/// The authored-raw-tag reader for the <c>Calculation</c> pseudo-driver: one authority, consumed by both
|
||||
/// the runtime (<c>CalculationTagDefinition.TryFromTagConfig</c>, which adds the RawPath identity and the
|
||||
/// script's dependency refs on top) and the deploy-time TagConfig gate (<see cref="Inspect"/>,
|
||||
/// deferment-register G-7).
|
||||
/// <para>
|
||||
/// <b>The JSON reading moved here from the driver; the definition-building did not.</b> Building a
|
||||
/// <c>CalculationTagDefinition</c> needs <c>EquipmentScriptPaths.ExtractDependencyRefs</c>, which
|
||||
/// lives in Commons and is genuinely runtime infrastructure — dragging it into a Contracts leaf to
|
||||
/// co-locate the whole thing would make every consumer of this gate carry the scripting stack. The
|
||||
/// split is at the blob boundary: everything the <i>blob</i> says is read here, once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadTagConfig"/>.</b> It reports
|
||||
/// only two things: a blob the read REJECTS (which the driver skips as "not a calc tag"), and fields
|
||||
/// that are present but in a shape the lenient read silently discards — <c>changeTriggered</c> that
|
||||
/// is not a JSON boolean (read as <see langword="true"/>) and <c>timerIntervalMs</c> that is not a
|
||||
/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently
|
||||
/// defaulted" warning class.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it deliberately does NOT warn about:</b> a tag with no trigger at all
|
||||
/// (<c>changeTriggered:false</c> and no timer). The AdminUI's <c>CalculationTagConfigModel.Validate</c>
|
||||
/// rejects that shape and the runtime accepts it, and this gate follows the <i>runtime</i> — it may
|
||||
/// never call a blob the driver serves invalid, because the deploy gate can be configured to turn its
|
||||
/// warnings into blocking errors. The same reasoning covers a sub-50 ms timer interval, which the
|
||||
/// editor refuses and the driver honours.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CalculationTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a calc tag's <c>TagConfig</c> blob
|
||||
/// (<c>{ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }</c>). Returns
|
||||
/// <see langword="false"/> — never throws — only when the blob is unreadable, is not a JSON object,
|
||||
/// or names no <c>scriptId</c>: a tag with no identifiable script cannot be a calc tag. A present
|
||||
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still reads (the driver logs it and the
|
||||
/// tag never computes).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
|
||||
/// <param name="fields">The read fields when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the blob names a script.</returns>
|
||||
public static bool TryReadTagConfig(string tagConfig, out CalculationTagConfigFields fields)
|
||||
{
|
||||
fields = default;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
var scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
|
||||
? sidEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(scriptId)) return false;
|
||||
|
||||
var scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
|
||||
var changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
|
||||
&& ctEl.ValueKind == JsonValueKind.False);
|
||||
|
||||
var timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
|
||||
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
|
||||
? ms : (int?)null;
|
||||
|
||||
fields = new CalculationTagConfigFields(scriptId!, scriptSource, changeTriggered, timerMs);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of a calc tag's <c>TagConfig</c> blob. Warns for a blob
|
||||
/// <see cref="TryReadTagConfig"/> rejects (unparseable JSON, a non-object root, or no usable
|
||||
/// <c>scriptId</c>), and for a present-but-discarded <c>changeTriggered</c> / <c>timerIntervalMs</c>
|
||||
/// — see the class remarks for what is deliberately NOT warned about. Empty when the blob is clean,
|
||||
/// blank, or not a JSON object at all. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
|
||||
return warnings;
|
||||
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(reference);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("Calculation TagConfig root is not a JSON object — the driver will skip this tag and it will never compute.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
if (!TryReadTagConfig(reference, out _))
|
||||
{
|
||||
warnings.Add(
|
||||
"Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not "
|
||||
+ "treat this as a calc tag, skips it, and it never computes.");
|
||||
}
|
||||
|
||||
// Present-but-discarded fields. The read is lenient here by design, so these say what was
|
||||
// IGNORED — they never claim the tag is invalid.
|
||||
if (root.TryGetProperty("changeTriggered", out var ct)
|
||||
&& ct.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
warnings.Add(
|
||||
$"value '{ct.GetRawText()}' for 'changeTriggered' is not a JSON boolean; anything but an "
|
||||
+ "explicit false is read as TRUE, so this tag is change-triggered whatever the value looks like.");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("timerIntervalMs", out var timer)
|
||||
&& !(timer.ValueKind == JsonValueKind.Number && timer.TryGetInt32(out var ms) && ms > 0))
|
||||
{
|
||||
warnings.Add(
|
||||
$"value '{timer.GetRawText()}' for 'timerIntervalMs' is not a positive whole number of "
|
||||
+ "milliseconds; it is discarded and the tag gets NO periodic re-evaluation.");
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- The calc-tag TagConfig reader, shared by the driver (which turns it into a
|
||||
CalculationTagDefinition) and the deploy-time TagConfig gate (G-7), mirroring
|
||||
Driver.MTConnect.Contracts / Driver.Modbus.Contracts.
|
||||
|
||||
Deliberately reference-free, unlike its siblings: a calc tag's blob is
|
||||
{ scriptId, scriptSource, changeTriggered, timerIntervalMs } — strings, a bool and an int, with
|
||||
no DriverDataType or other Core.Abstractions type in it. Carrying the leaf reference anyway
|
||||
would be dead weight that reads as a dependency. Add it the moment a field needs it. -->
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
@@ -40,45 +39,20 @@ public sealed record CalculationTagDefinition(
|
||||
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
|
||||
string? scriptId;
|
||||
string scriptSource;
|
||||
bool changeTriggered;
|
||||
int? timerMs;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
// The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate
|
||||
// (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says
|
||||
// (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency
|
||||
// refs extracted from the script source.
|
||||
if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false;
|
||||
|
||||
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
|
||||
? sidEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(scriptId)) return false;
|
||||
|
||||
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
|
||||
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
|
||||
&& ctEl.ValueKind == JsonValueKind.False);
|
||||
|
||||
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
|
||||
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
|
||||
? ms : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
|
||||
var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource);
|
||||
def = new CalculationTagDefinition(
|
||||
RawPath: rawPath,
|
||||
ScriptId: scriptId!,
|
||||
ScriptSource: scriptSource,
|
||||
ChangeTriggered: changeTriggered,
|
||||
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
|
||||
ScriptId: fields.ScriptId,
|
||||
ScriptSource: fields.ScriptSource,
|
||||
ChangeTriggered: fields.ChangeTriggered,
|
||||
TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
|
||||
DependencyRefs: deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
+3
@@ -19,6 +19,9 @@
|
||||
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
|
||||
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
|
||||
owns DataValueSnapshot. -->
|
||||
<!-- The calc tag's TagConfig reader (CalculationTagDefinitionFactory), shared with the deploy-time
|
||||
TagConfig gate so the two cannot drift (G-7). -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime
|
||||
/// (<c>MTConnectDriver</c>'s <c>RawPath → definition</c> table build) and the deploy-time TagConfig gate
|
||||
/// (<see cref="Inspect"/>, deferment-register G-7).
|
||||
/// <para>
|
||||
/// <b>This parse used to be private to <c>MTConnectDriver</c>.</b> It moved here — unchanged —
|
||||
/// rather than being mirrored, because a mirror of an accept/reject boundary drifts the moment
|
||||
/// either side is touched, and the drift is invisible: the gate would go on describing a shape the
|
||||
/// driver no longer reads. Nothing about the reading changed in the move; the driver now calls
|
||||
/// <see cref="TryFromTagConfig"/> where it called its own copy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryFromTagConfig"/></b> — it asks the
|
||||
/// parse for the verdict and only explains a rejection, so a blob the driver serves can never draw
|
||||
/// a warning calling it invalid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MTConnectTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Reader options, mirroring <c>MTConnectDriver</c>'s config reader (from which this moved). Note
|
||||
/// there is deliberately <b>no</b> <c>JsonStringEnumConverter</c>: the type-carrying field stays a
|
||||
/// <c>string?</c> and is parsed by name below, which is the project-wide guard against the
|
||||
/// numerically-serialized-enum trap.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads one authored raw tag's <c>TagConfig</c> blob: the DataItem id it binds, and the definition
|
||||
/// its blob declares (<see langword="null"/> when the blob declares no type and the caller must fall
|
||||
/// back to the Agent's <c>/probe</c> declaration). Never throws.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="dataItemId">The Agent DataItem id the tag binds, when this returns <see langword="true"/>.</param>
|
||||
/// <param name="authored">The definition the blob declared, or <see langword="null"/> when it declared no type.</param>
|
||||
/// <returns>
|
||||
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
|
||||
/// <c>driverDataType</c>/<c>dataType</c> that is not a <see cref="DriverDataType"/> — a
|
||||
/// present-but-invalid type rejects the tag rather than silently defaulting, which would publish the
|
||||
/// Agent's text as a Good value of the wrong type (the sibling drivers' strict-enum posture).
|
||||
/// </returns>
|
||||
public static bool TryFromTagConfig(string? tagConfig, out string dataItemId, out MTConnectTagDefinition? authored)
|
||||
{
|
||||
dataItemId = string.Empty;
|
||||
authored = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
|
||||
MTConnectRawTagConfigDto? blob;
|
||||
try
|
||||
{
|
||||
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(tagConfig, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blob is null) return false;
|
||||
|
||||
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
|
||||
if (id is null) return false;
|
||||
|
||||
dataItemId = id;
|
||||
|
||||
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
|
||||
// MTConnectTagConfigModel writes) — see the DTO remarks.
|
||||
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
|
||||
if (authoredType is null) return true;
|
||||
|
||||
if (!TryParseDataType(authoredType, out var parsed)) return false;
|
||||
|
||||
authored = new MTConnectTagDefinition(
|
||||
dataItemId,
|
||||
parsed,
|
||||
blob.IsArray ?? false,
|
||||
blob.ArrayDim ?? 0,
|
||||
blob.MtCategory,
|
||||
blob.MtType,
|
||||
blob.MtSubType,
|
||||
blob.Units);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
|
||||
/// <see cref="TryFromTagConfig"/> would reject — unparseable JSON, no DataItem id under any of the
|
||||
/// three accepted spellings, or a present-but-invalid coercion type (including the digits-only
|
||||
/// numerically-serialized-enum shape). Empty when the parse accepts the blob, when it is blank, or
|
||||
/// when it is not a JSON object at all. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
|
||||
return warnings;
|
||||
|
||||
// The accept gate: whatever the driver reads draws nothing. See the class remarks.
|
||||
if (TryFromTagConfig(reference, out _, out _)) return warnings;
|
||||
|
||||
MTConnectRawTagConfigDto? blob;
|
||||
try
|
||||
{
|
||||
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(reference, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("MTConnect TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
if (blob is null)
|
||||
{
|
||||
warnings.Add("MTConnect TagConfig is a JSON null — the tag binds no DataItem and will be skipped.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
if (FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address) is null)
|
||||
{
|
||||
warnings.Add(
|
||||
"MTConnect TagConfig names no DataItem id (expected 'fullName', 'dataItemId' or 'address') — "
|
||||
+ "the driver will skip this tag and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
|
||||
if (authoredType is not null && !TryParseDataType(authoredType, out _))
|
||||
{
|
||||
var field = string.IsNullOrWhiteSpace(blob.DriverDataType) ? "dataType" : "driverDataType";
|
||||
warnings.Add(
|
||||
$"value '{authoredType}' for '{field}' is not a valid DriverDataType name; valid: "
|
||||
+ string.Join(", ", Enum.GetNames<DriverDataType>()));
|
||||
}
|
||||
|
||||
if (warnings.Count == 0)
|
||||
{
|
||||
warnings.Add(
|
||||
"MTConnect TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
|
||||
+ "and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
|
||||
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
|
||||
/// as <c>"Float64"</c> and returns the member with that ordinal, so a numerically-serialized enum
|
||||
/// (the repo-wide authoring trap) would otherwise coerce every value of that tag to a type nobody
|
||||
/// chose, under Good quality.
|
||||
/// </summary>
|
||||
/// <param name="raw">The authored type text.</param>
|
||||
/// <param name="parsed">The parsed type when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="raw"/> is a defined member NAME.</returns>
|
||||
private static bool TryParseDataType(string raw, out DriverDataType parsed)
|
||||
{
|
||||
parsed = default;
|
||||
if (raw.All(char.IsAsciiDigit)) return false;
|
||||
return Enum.TryParse(raw, ignoreCase: true, out parsed) && Enum.IsDefined(parsed);
|
||||
}
|
||||
|
||||
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
|
||||
/// <param name="candidates">The candidate values, in precedence order.</param>
|
||||
/// <returns>The first non-blank candidate, trimmed, or null.</returns>
|
||||
private static string? FirstNonBlank(params string?[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
|
||||
/// Every field is optional — the blob is operator-authorable and reaches the driver through
|
||||
/// several authoring surfaces that populate different subsets of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
|
||||
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
|
||||
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
|
||||
/// hand-authoring the blob reaches for first), and <c>address</c> (what
|
||||
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
|
||||
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
|
||||
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
|
||||
/// address picker silently unreadable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
|
||||
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
|
||||
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
|
||||
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
|
||||
/// and the driver disagree about the type of every tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Kept separate from the driver's strict <c>tags[]</c> DTO rather than reusing it: there a
|
||||
/// missing <c>fullName</c> is a config error worth failing the deployment over. A raw tag's
|
||||
/// blob is authored elsewhere and its failure mode is per-tag, not per-driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MTConnectRawTagConfigDto
|
||||
{
|
||||
public string? FullName { get; init; }
|
||||
public string? DataItemId { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? DriverDataType { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? IsArray { get; init; }
|
||||
public int? ArrayDim { get; init; }
|
||||
public string? MtCategory { get; init; }
|
||||
public string? MtType { get; init; }
|
||||
public string? MtSubType { get; init; }
|
||||
public string? Units { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver
|
||||
{
|
||||
if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue;
|
||||
|
||||
if (!TryReadRawTag(entry, out var dataItemId, out var authored))
|
||||
if (!MTConnectTagDefinitionFactory.TryFromTagConfig(entry.TagConfig, out var dataItemId, out var authored))
|
||||
{
|
||||
_skippedTags.Skip(
|
||||
entry.RawPath,
|
||||
@@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares
|
||||
/// (null when the blob declares no type and the caller must fall back).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
|
||||
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — a present-but-invalid
|
||||
/// type rejects the tag rather than silently defaulting, which would publish the Agent's text
|
||||
/// as a Good value of the wrong type (the sibling drivers' strict-enum posture).
|
||||
/// </returns>
|
||||
private static bool TryReadRawTag(
|
||||
RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored)
|
||||
{
|
||||
dataItemId = string.Empty;
|
||||
authored = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false;
|
||||
|
||||
MTConnectRawTagConfigDto? blob;
|
||||
try
|
||||
{
|
||||
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(entry.TagConfig, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blob is null) return false;
|
||||
|
||||
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
|
||||
if (id is null) return false;
|
||||
|
||||
dataItemId = id;
|
||||
|
||||
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
|
||||
// MTConnectTagConfigModel writes) — see the DTO remarks.
|
||||
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
|
||||
if (authoredType is null) return true;
|
||||
|
||||
// A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as
|
||||
// readily as "Float64" and returns the member with that ordinal, so a numerically-serialized
|
||||
// enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a
|
||||
// type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate
|
||||
// refuses the same shape; this is the runtime half of that guard.
|
||||
if (authoredType.All(char.IsAsciiDigit)
|
||||
|| !Enum.TryParse<DriverDataType>(authoredType, ignoreCase: true, out var parsed)
|
||||
|| !Enum.IsDefined(parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
authored = new MTConnectTagDefinition(
|
||||
dataItemId,
|
||||
parsed,
|
||||
blob.IsArray ?? false,
|
||||
blob.ArrayDim ?? 0,
|
||||
blob.MtCategory,
|
||||
blob.MtType,
|
||||
blob.MtSubType,
|
||||
blob.Units);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
|
||||
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
|
||||
@@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
|
||||
private static string? FirstNonBlank(params string?[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
|
||||
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at
|
||||
@@ -3735,51 +3659,6 @@ public sealed class MTConnectDriver
|
||||
public MTConnectWriteDto? Write { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
|
||||
/// Every field is optional — the blob is operator-authorable and reaches the driver through
|
||||
/// several authoring surfaces that populate different subsets of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
|
||||
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
|
||||
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
|
||||
/// hand-authoring the blob reaches for first), and <c>address</c> (what
|
||||
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
|
||||
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
|
||||
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
|
||||
/// address picker silently unreadable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
|
||||
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
|
||||
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
|
||||
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
|
||||
/// and the driver disagree about the type of every tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
|
||||
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> is a config error
|
||||
/// worth failing the deployment over. A raw tag's blob is authored elsewhere and its
|
||||
/// failure mode is per-tag, not per-driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MTConnectRawTagConfigDto
|
||||
{
|
||||
public string? FullName { get; init; }
|
||||
public string? DataItemId { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? DriverDataType { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? IsArray { get; init; }
|
||||
public int? ArrayDim { get; init; }
|
||||
public string? MtCategory { get; init; }
|
||||
public string? MtType { get; init; }
|
||||
public string? MtSubType { get; init; }
|
||||
public string? Units { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
|
||||
private sealed class MTConnectTagDto
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -119,37 +118,14 @@ public sealed class NamespaceMap
|
||||
foreach (var entry in rawTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.RawPath)) continue;
|
||||
if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
|
||||
// The blob read lives on OpcUaClientTagDefinitionFactory so the deploy-time TagConfig gate and
|
||||
// this table build cannot disagree about what a usable nodeId is (G-7).
|
||||
if (!OpcUaClientTagDefinitionFactory.TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
|
||||
table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
|
||||
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
|
||||
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
|
||||
/// </summary>
|
||||
private static bool TryReadNodeId(string tagConfig, out string nodeId)
|
||||
{
|
||||
nodeId = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
|
||||
&& n.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var s = n.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* malformed blob — treat as unresolvable */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
|
||||
public int Count => _uris.Length;
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both
|
||||
/// the runtime (<see cref="NamespaceMap"/>'s <c>RawPath → upstream node id</c> table build) and the
|
||||
/// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7).
|
||||
/// <para>
|
||||
/// <b>This read used to be private to <see cref="NamespaceMap"/>.</b> It moved here — unchanged —
|
||||
/// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side
|
||||
/// is touched, and the drift is silent, with the gate describing a shape the driver no longer
|
||||
/// reads. <see cref="NamespaceMap"/> now calls <see cref="TryReadNodeId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — it asks the
|
||||
/// read for the verdict and only explains a rejection, so a blob the driver resolves can never draw
|
||||
/// a warning calling it invalid. Nothing else in the blob is checked: the upstream node id is an
|
||||
/// opaque reference string that the SDK re-binds against the live session, so whether it names a
|
||||
/// node that exists is knowable only at connect time, not at deploy time.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OpcUaClientTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
|
||||
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
|
||||
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="nodeId">The upstream node id string when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the blob carries a usable upstream node reference.</returns>
|
||||
public static bool TryReadNodeId(string tagConfig, out string nodeId)
|
||||
{
|
||||
nodeId = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
|
||||
&& n.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var s = n.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* malformed blob — treat as unresolvable */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
|
||||
/// <see cref="TryReadNodeId"/> would reject — unparseable JSON, a non-object root, or no usable
|
||||
/// <c>nodeId</c> (absent, non-string, or blank). Such a tag is dropped from the driver's resolution
|
||||
/// table and reads <c>BadNodeIdInvalid</c> forever, which before Gitea #541 was the quietest skip
|
||||
/// of the twelve drivers — not even a log line. Empty when the blob resolves, is blank, or is not a
|
||||
/// JSON object at all. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
|
||||
return warnings;
|
||||
|
||||
// The accept gate: whatever the driver resolves draws nothing. See the class remarks.
|
||||
if (TryReadNodeId(reference, out _)) return warnings;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add(
|
||||
"OpcUaClient TagConfig root is not a JSON object — the tag binds no upstream node and "
|
||||
+ "will read BadNodeIdInvalid.");
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("OpcUaClient TagConfig is not valid JSON — the tag will not resolve (BadNodeIdInvalid).");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
warnings.Add(
|
||||
"OpcUaClient TagConfig has no usable 'nodeId' (a non-blank string holding the upstream "
|
||||
+ "nsu=…/ns=N;… reference) — the driver drops this tag from its resolution table and it reads "
|
||||
+ "BadNodeIdInvalid.");
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of a Sql raw tag's <c>TagConfig</c> blob (deferment-register G-7). The Sql
|
||||
/// driver already owns its runtime parse in this assembly
|
||||
/// (<see cref="SqlEquipmentTagParser.TryParse"/>), so this type adds <b>only</b> the operator-facing
|
||||
/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a
|
||||
/// rejection.
|
||||
/// <para>
|
||||
/// <b>Why the verdict comes from the parser and not from a mirror.</b> The one rule this gate must
|
||||
/// never break is being stricter than the runtime: a blob the driver happily serves must not draw a
|
||||
/// deploy warning calling it invalid. Running <see cref="SqlEquipmentTagParser.TryParse"/> first and
|
||||
/// returning empty on success makes that structural rather than a promise — a warning is reachable
|
||||
/// only for a blob the driver would have refused (and then skipped silently, reading
|
||||
/// <c>BadNodeIdUnknown</c> forever).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The class is named <c>SqlTagDefinitionFactory</c> to match the six sibling
|
||||
/// <c>*TagDefinitionFactory.Inspect</c> entry points the deploy gate discovers by reflection
|
||||
/// (<c>EquipmentTagConfigInspectorCoverageTests</c>); the mapping half of a "factory" already lives
|
||||
/// on <see cref="SqlEquipmentTagParser"/> and is deliberately not duplicated here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SqlTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// The RawPath handed to the parser during inspection. The parser only copies it onto
|
||||
/// <see cref="SqlTagDefinition.Name"/> and never validates it, so inspection is free to pass a
|
||||
/// placeholder — the deploy gate is inspecting a blob, not a placed tag.
|
||||
/// </summary>
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable warnings
|
||||
/// for a blob <see cref="SqlEquipmentTagParser.TryParse"/> would reject — an unparseable or
|
||||
/// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid
|
||||
/// <c>model</c> / <c>type</c> enum, the P3-deferred <c>Query</c> model, a missing <c>table</c>, or a
|
||||
/// missing model-required field. Empty when the parser accepts the blob, when the blob is blank, or
|
||||
/// when it is not a JSON object at all (the sibling factories' "not an equipment-tag TagConfig"
|
||||
/// convention). Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
|
||||
return warnings;
|
||||
|
||||
// The accept gate: whatever the driver serves draws nothing. See the class remarks.
|
||||
if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("Sql TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
DescribeRejection(root, warnings);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("Sql TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// Backstop: the parser refused for a reason nothing above names (it also swallows FormatException).
|
||||
// Saying so is worth more than reporting a clean blob the driver will silently skip.
|
||||
if (warnings.Count == 0)
|
||||
{
|
||||
warnings.Add(
|
||||
"Sql TagConfig was rejected by the driver's tag parser — the driver will skip this tag and it "
|
||||
+ "will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
|
||||
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
|
||||
/// runs only after the parser has already refused, so it can never invent strictness of its own.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <param name="warnings">The list to append warnings to.</param>
|
||||
private static void DescribeRejection(JsonElement root, List<string> warnings)
|
||||
{
|
||||
// Recognition (parser: a "driver":"Sql" marker or a "model" discriminator).
|
||||
var hasModel = root.TryGetProperty("model", out _);
|
||||
var driver = ReadString(root, "driver");
|
||||
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add(
|
||||
"Sql TagConfig carries neither a 'model' field nor 'driver':'Sql', so the driver does not "
|
||||
+ "recognise it as a Sql tag and will skip it (BadNodeIdUnknown).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Strict enums: a typo'd value rejects the whole tag rather than silently defaulting.
|
||||
var modelWarning = TagConfigJson.DescribeInvalidEnum<SqlTagModel>(root, "model");
|
||||
if (modelWarning is not null) warnings.Add(modelWarning);
|
||||
var typeWarning = TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "type");
|
||||
if (typeWarning is not null) warnings.Add(typeWarning);
|
||||
if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run
|
||||
|
||||
var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue);
|
||||
if (model == SqlTagModel.Query)
|
||||
{
|
||||
warnings.Add(
|
||||
"Sql tag model 'Query' is deferred (design §5.4 / P3) and is not served — the driver will "
|
||||
+ "skip this tag.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ReadString(root, "table")))
|
||||
warnings.Add("Sql TagConfig has no 'table' — every Sql tag model requires one.");
|
||||
|
||||
switch (model)
|
||||
{
|
||||
case SqlTagModel.KeyValue:
|
||||
if (string.IsNullOrWhiteSpace(ReadString(root, "keyColumn")))
|
||||
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).");
|
||||
break;
|
||||
|
||||
case SqlTagModel.WideRow:
|
||||
if (string.IsNullOrWhiteSpace(ReadString(root, "columnName")))
|
||||
warnings.Add("Sql WideRow tag has no 'columnName'.");
|
||||
if (!HasRowSelector(root))
|
||||
{
|
||||
warnings.Add(
|
||||
"Sql WideRow tag has no usable 'rowSelector' — it needs a 'whereColumn'/'whereValue' "
|
||||
+ "pair or a 'topByTimestamp' column, or the driver cannot tell which row to read.");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a WideRow blob names a row the driver can select, mirroring the parser's
|
||||
/// <c>hasWherePair || topByTimestamp</c> test (a where-pair needs BOTH keys, though
|
||||
/// <c>whereValue</c> may legitimately be an empty string).
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <returns><see langword="true"/> when the blob names a selectable row.</returns>
|
||||
private static bool HasRowSelector(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("rowSelector", out var sel) || sel.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
var hasWherePair = !string.IsNullOrWhiteSpace(ReadString(sel, "whereColumn"))
|
||||
&& HasScalar(sel, "whereValue");
|
||||
return hasWherePair || !string.IsNullOrWhiteSpace(ReadString(sel, "topByTimestamp"));
|
||||
}
|
||||
|
||||
/// <summary>Reads a string-valued property; null when absent or not a JSON string (as the parser does).</summary>
|
||||
/// <param name="o">The object to read from.</param>
|
||||
/// <param name="name">The property name.</param>
|
||||
/// <returns>The string value, or null.</returns>
|
||||
private static string? ReadString(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
|
||||
|
||||
/// <summary>Whether a property is present as a scalar the parser's <c>ReadScalar</c> would capture.</summary>
|
||||
/// <param name="o">The object to read from.</param>
|
||||
/// <param name="name">The property name.</param>
|
||||
/// <returns><see langword="true"/> when the property is a string, number or boolean.</returns>
|
||||
private static bool HasScalar(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e)
|
||||
&& e.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False;
|
||||
}
|
||||
Reference in New Issue
Block a user