diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 81e15e0c..1bff1974 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -93,6 +93,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs new file mode 100644 index 00000000..006089f8 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a +/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is +/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator; +/// the model and type enums are read with +/// , so a present-but-invalid (typo'd) value rejects +/// the whole tag — the driver then surfaces BadNodeIdUnknown instead of silently defaulting to a +/// different model and publishing a misleading Good (R2-11). +/// No SQL is built here. The parser only captures strings; the reader binds value fields as +/// DbParameters and validates + quotes identifier fields. Tag input is never concatenated into a +/// command text, so a hostile keyValue is stored — and must be stored — verbatim. +/// +public static class SqlEquipmentTagParser +{ + /// + /// Maps an authored TagConfig blob to a typed definition keyed by . + /// Returns — never throws — for anything the driver must not serve: a + /// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or + /// type enum, a missing model-required field, or the P3-deferred + /// model. + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid Sql tag blob. + 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; } + } + + /// Reads a string-valued property; null when absent or not a JSON string. + private static string? ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + /// + /// 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 whereValue of either shape is captured + /// verbatim for later parameter binding. Null when absent or not a scalar. + /// + 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, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs new file mode 100644 index 00000000..755e2b9d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's TagConfig blob +/// (design §5.2 / §5.3), produced by . +/// Every string here is captured verbatim from the authored blob and is NEVER concatenated +/// into SQL. Value-bearing fields (, ) are bound +/// as DbParameters by the reader; identifier-bearing fields (, +/// , , , +/// , , +/// ) must be validated against INFORMATION_SCHEMA and +/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt +/// legitimate values, so the parser deliberately does not. +/// +/// +/// The tag's identity — its RawPath, 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. +/// +/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only. +/// The table or view to read (e.g. dbo.TagValues). +/// : the column matched against . +/// : this tag's key — bound, never interpolated. +/// : the column the value is read from. +/// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. +/// : the column this tag reads from the selected row. +/// : the whereColumn that picks the row. +/// : the whereValuebound, never interpolated. +/// : newest-row selector — the timestamp column to order by, when no where-pair is authored. +/// +/// Optional explicit type override from the blob's type field. ⇒ the +/// driver infers the type from the result set's column metadata. +/// +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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs new file mode 100644 index 00000000..18177ec4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs @@ -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(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj new file mode 100644 index 00000000..74a7aae6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + +