feat(sql): SqlTagDefinition + strict-enum equipment-tag parser

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 13:36:51 -04:00
parent 5b7fe9336e
commit 53ff041229
5 changed files with 236 additions and 0 deletions
+1
View File
@@ -93,6 +93,7 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
@@ -0,0 +1,131 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Pure mapper from an authored raw tag's <c>TagConfig</c> JSON (design §5.2 / §5.3) to a
/// <see cref="SqlTagDefinition"/>, mirroring <c>ModbusTagDefinitionFactory.FromTagConfig</c>. The blob is
/// recognised by a leading <c>{</c> plus a <c>"driver":"Sql"</c> marker or a <c>"model"</c> discriminator;
/// the <c>model</c> and <c>type</c> enums are read with
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so a present-but-invalid (typo'd) value rejects
/// the whole tag — the driver then surfaces <c>BadNodeIdUnknown</c> instead of silently defaulting to a
/// different model and publishing a misleading <c>Good</c> (R2-11).
/// <para><b>No SQL is built here.</b> The parser only captures strings; the reader binds value fields as
/// <c>DbParameter</c>s and validates + quotes identifier fields. Tag input is never concatenated into a
/// command text, so a hostile <c>keyValue</c> is stored — and must be stored — verbatim.</para>
/// </summary>
public static class SqlEquipmentTagParser
{
/// <summary>
/// Maps an authored <c>TagConfig</c> blob to a typed definition keyed by <paramref name="rawPath"/>.
/// Returns <see langword="false"/> — never throws — for anything the driver must not serve: a
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd <c>model</c> or
/// <c>type</c> enum, a missing model-required field, or the P3-deferred
/// <see cref="SqlTagModel.Query"/> model.
/// </summary>
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(reference)) return false;
if (reference.TrimStart().FirstOrDefault() != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// Recognition: either the explicit driver marker or the model discriminator must be present,
// so another driver's TagConfig blob is not mistaken for a Sql one.
var driver = ReadString(root, "driver");
var hasModel = root.TryGetProperty("model", out _);
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
// Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
DriverDataType? declaredType =
root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
? type
: null;
var table = ReadString(root, "table");
if (string.IsNullOrWhiteSpace(table)) return false;
var timestampColumn = ReadString(root, "timestampColumn");
switch (model)
{
case SqlTagModel.KeyValue:
{
var keyColumn = ReadString(root, "keyColumn");
var keyValue = ReadString(root, "keyValue");
var valueColumn = ReadString(root, "valueColumn");
if (string.IsNullOrWhiteSpace(keyColumn)
|| string.IsNullOrWhiteSpace(valueColumn)
|| keyValue is null)
return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
TimestampColumn: timestampColumn, DeclaredType: declaredType);
return true;
}
case SqlTagModel.WideRow:
{
var columnName = ReadString(root, "columnName");
if (string.IsNullOrWhiteSpace(columnName)) return false;
string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
{
selectorColumn = ReadString(sel, "whereColumn");
selectorValue = ReadScalar(sel, "whereValue");
topByTimestamp = ReadString(sel, "topByTimestamp");
}
// A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
TimestampColumn: timestampColumn, ColumnName: columnName,
RowSelectorColumn: hasWherePair ? selectorColumn : null,
RowSelectorValue: hasWherePair ? selectorValue : null,
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
DeclaredType: declaredType);
return true;
}
default:
// SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
// the runtime cannot read.
return false;
}
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string.</summary>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <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.
/// </summary>
private static string? ReadScalar(JsonElement o, string name)
{
if (!o.TryGetProperty(name, out var e)) return null;
return e.ValueKind switch
{
JsonValueKind.String => e.GetString(),
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
_ => null,
};
}
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's <c>TagConfig</c> blob
/// (design §5.2 / §5.3), produced by <see cref="SqlEquipmentTagParser.TryParse"/>.
/// <para><b>Every string here is captured verbatim from the authored blob and is NEVER concatenated
/// into SQL.</b> Value-bearing fields (<see cref="KeyValue"/>, <see cref="RowSelectorValue"/>) are bound
/// as <c>DbParameter</c>s by the reader; identifier-bearing fields (<see cref="Table"/>,
/// <see cref="KeyColumn"/>, <see cref="ValueColumn"/>, <see cref="TimestampColumn"/>,
/// <see cref="ColumnName"/>, <see cref="RowSelectorColumn"/>,
/// <see cref="RowSelectorTopByTimestamp"/>) must be validated against <c>INFORMATION_SCHEMA</c> and
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
/// legitimate values, so the parser deliberately does not.</para>
/// </summary>
/// <param name="Name">
/// The tag's identity — its <b>RawPath</b>, exactly as handed to the parser. The driver's forward
/// router keys published values on this, so it must never be derived from the blob's contents.
/// </param>
/// <param name="Model">Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.</param>
/// <param name="Table">The table or view to read (e.g. <c>dbo.TagValues</c>).</param>
/// <param name="KeyColumn"><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</param>
/// <param name="KeyValue"><see cref="SqlTagModel.KeyValue"/>: this tag's key — <b>bound</b>, never interpolated.</param>
/// <param name="ValueColumn"><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</param>
/// <param name="TimestampColumn">Optional source-timestamp column; absent ⇒ the driver stamps the poll time.</param>
/// <param name="ColumnName"><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</param>
/// <param name="RowSelectorColumn"><see cref="SqlTagModel.WideRow"/>: the <c>whereColumn</c> that picks the row.</param>
/// <param name="RowSelectorValue"><see cref="SqlTagModel.WideRow"/>: the <c>whereValue</c> — <b>bound</b>, never interpolated.</param>
/// <param name="RowSelectorTopByTimestamp"><see cref="SqlTagModel.WideRow"/>: newest-row selector — the timestamp column to order by, when no where-pair is authored.</param>
/// <param name="DeclaredType">
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
/// driver infers the type from the result set's column metadata.
/// </param>
public sealed record SqlTagDefinition(
string Name,
SqlTagModel Model,
string Table,
string? KeyColumn = null,
string? KeyValue = null,
string? ValueColumn = null,
string? TimestampColumn = null,
string? ColumnName = null,
string? RowSelectorColumn = null,
string? RowSelectorValue = null,
string? RowSelectorTopByTimestamp = null,
DriverDataType? DeclaredType = null);
@@ -0,0 +1,30 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
public class SqlEquipmentTagParserTests
{
[Fact]
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
{
var json = """
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
def.DeclaredType.ShouldBe(DriverDataType.Float64);
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>