feat(sql): SqlTagDefinition + strict-enum equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
+27
@@ -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>
|
||||||
Reference in New Issue
Block a user