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