feat(sql): typed AdminUI Sql tag-config model + validator (string enums)
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's accept/reject boundary byte-for-byte on the fields they own: model/type serialize as NAME strings (never numbers), KeyValue requires table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys (top-level and nested rowSelector) survive load->save. Registered in TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName (DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI. The load-bearing test rounds editor output back through SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
|||||||
|
@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only
|
||||||
|
wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no
|
||||||
|
edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields
|
||||||
|
(KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the
|
||||||
|
same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor
|
||||||
|
takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||||
|
|
||||||
|
<div class="text-muted small">Sql tag editor — coming soon (Task 20).</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The tag's current TagConfig JSON.</summary>
|
||||||
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised with the updated TagConfig JSON when the model changes.</summary>
|
||||||
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
|
private SqlTagConfigModel _m = new();
|
||||||
|
private string? _lastConfigJson;
|
||||||
|
|
||||||
|
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset
|
||||||
|
// an in-progress edit (mirrors the other typed editors).
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (ConfigJson == _lastConfigJson) { return; }
|
||||||
|
_lastConfigJson = ConfigJson;
|
||||||
|
_m = SqlTagConfigModel.FromJson(ConfigJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column
|
||||||
|
/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the
|
||||||
|
/// authoritative runtime reader <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
|
||||||
|
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
|
||||||
|
/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
|
||||||
|
/// (top-level and inside <c>rowSelector</c>) across a load→save so a future field survives an edit.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlTagConfigModel
|
||||||
|
{
|
||||||
|
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
|
||||||
|
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
|
||||||
|
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
|
||||||
|
|
||||||
|
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
|
||||||
|
public string? Table { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
|
||||||
|
public string? KeyColumn { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
|
||||||
|
public string? KeyValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
|
||||||
|
public string? ValueColumn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.</summary>
|
||||||
|
public string? TimestampColumn { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</summary>
|
||||||
|
public string? ColumnName { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
|
||||||
|
public SqlRowSelectorModel RowSelector { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
|
||||||
|
/// driver infers the type from the result-set column metadata.</summary>
|
||||||
|
public DriverDataType? Type { get; set; }
|
||||||
|
|
||||||
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
|
// The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's
|
||||||
|
// strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate).
|
||||||
|
private string? _invalidModelRaw;
|
||||||
|
private string? _invalidTypeRaw;
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||||
|
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||||
|
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||||
|
/// <returns>The populated <see cref="SqlTagConfigModel"/>.</returns>
|
||||||
|
public static SqlTagConfigModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
var (model, invalidModel) = ReadEnumStrict<SqlTagModel>(o, "model");
|
||||||
|
var (type, invalidType) = ReadEnumStrict<DriverDataType>(o, "type");
|
||||||
|
|
||||||
|
var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null;
|
||||||
|
|
||||||
|
return new SqlTagConfigModel
|
||||||
|
{
|
||||||
|
Model = model ?? SqlTagModel.KeyValue,
|
||||||
|
Table = TagConfigJson.GetString(o, "table"),
|
||||||
|
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
|
||||||
|
KeyValue = TagConfigJson.GetString(o, "keyValue"),
|
||||||
|
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
|
||||||
|
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
|
||||||
|
ColumnName = TagConfigJson.GetString(o, "columnName"),
|
||||||
|
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
|
||||||
|
Type = type,
|
||||||
|
_bag = o,
|
||||||
|
_invalidModelRaw = invalidModel,
|
||||||
|
_invalidTypeRaw = invalidType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as
|
||||||
|
/// their name strings) over the preserved key bag. The nested <c>rowSelector</c> object is rebuilt from
|
||||||
|
/// <see cref="RowSelector"/> (preserving its own unknown keys) and omitted entirely when empty.</summary>
|
||||||
|
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "model", Model);
|
||||||
|
TagConfigJson.Set(_bag, "table", Table);
|
||||||
|
TagConfigJson.Set(_bag, "keyColumn", KeyColumn);
|
||||||
|
TagConfigJson.Set(_bag, "keyValue", KeyValue);
|
||||||
|
TagConfigJson.Set(_bag, "valueColumn", ValueColumn);
|
||||||
|
TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn);
|
||||||
|
TagConfigJson.Set(_bag, "columnName", ColumnName);
|
||||||
|
TagConfigJson.Set(_bag, "type", Type);
|
||||||
|
|
||||||
|
var selector = RowSelector.ToJsonObject();
|
||||||
|
if (selector is null) { _bag.Remove("rowSelector"); }
|
||||||
|
else { _bag["rowSelector"] = selector; }
|
||||||
|
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validates the model against the exact accept/reject boundary of
|
||||||
|
/// <see cref="SqlEquipmentTagParser.TryParse"/>. Returns an error message, or <c>null</c> when valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
{
|
||||||
|
// Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag.
|
||||||
|
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
|
||||||
|
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
|
||||||
|
|
||||||
|
// Query is design §5.4 / P3 — the parser rejects it; the editor must too.
|
||||||
|
if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; }
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; }
|
||||||
|
|
||||||
|
switch (Model)
|
||||||
|
{
|
||||||
|
case SqlTagModel.KeyValue:
|
||||||
|
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
|
||||||
|
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
|
||||||
|
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case SqlTagModel.WideRow:
|
||||||
|
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
|
||||||
|
// A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but
|
||||||
|
// must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`.
|
||||||
|
var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null;
|
||||||
|
if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp))
|
||||||
|
{
|
||||||
|
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "model is not a supported Sql tag model.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
|
||||||
|
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
|
||||||
|
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it.
|
||||||
|
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var s))
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for a <see cref="SqlTagModel.WideRow"/> tag's nested <c>rowSelector</c> object.
|
||||||
|
/// Holds a where-pair (<see cref="WhereColumn"/>/<see cref="WhereValue"/>) or a newest-by-timestamp
|
||||||
|
/// selector (<see cref="TopByTimestamp"/>). Preserves unknown keys inside the selector across load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlRowSelectorModel
|
||||||
|
{
|
||||||
|
/// <summary>The column that picks the row (matched against <see cref="WhereValue"/>).</summary>
|
||||||
|
public string? WhereColumn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The value <see cref="WhereColumn"/> is matched against (bound as a parameter at read time).
|
||||||
|
/// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring
|
||||||
|
/// the parser's scalar read.</summary>
|
||||||
|
public string? WhereValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Newest-row selector: the timestamp column to order by (used when no where-pair is authored).</summary>
|
||||||
|
public string? TopByTimestamp { get; set; }
|
||||||
|
|
||||||
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a selector model from the nested <c>rowSelector</c> JSON object (or a fresh empty model
|
||||||
|
/// when the object is absent), retaining any unknown keys.</summary>
|
||||||
|
/// <param name="o">The nested <c>rowSelector</c> object, or <c>null</c> when absent.</param>
|
||||||
|
/// <returns>The populated <see cref="SqlRowSelectorModel"/>.</returns>
|
||||||
|
public static SqlRowSelectorModel FromJson(JsonObject? o)
|
||||||
|
{
|
||||||
|
// Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson).
|
||||||
|
var bag = o?.DeepClone().AsObject() ?? new JsonObject();
|
||||||
|
return new SqlRowSelectorModel
|
||||||
|
{
|
||||||
|
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
|
||||||
|
WhereValue = ReadScalarText(bag, "whereValue"),
|
||||||
|
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
|
||||||
|
_bag = bag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuilds the nested <c>rowSelector</c> JSON object from the exposed fields over the preserved
|
||||||
|
/// key bag, or returns <c>null</c> when the selector carries nothing (so the parent omits the key).</summary>
|
||||||
|
/// <returns>The <c>rowSelector</c> <see cref="JsonObject"/>, or <c>null</c> when empty.</returns>
|
||||||
|
public JsonObject? ToJsonObject()
|
||||||
|
{
|
||||||
|
var o = _bag.DeepClone().AsObject();
|
||||||
|
TagConfigJson.Set(o, "whereColumn", WhereColumn);
|
||||||
|
TagConfigJson.Set(o, "whereValue", WhereValue);
|
||||||
|
TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp);
|
||||||
|
return o.Count == 0 ? null : o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw
|
||||||
|
// token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar).
|
||||||
|
private static string? ReadScalarText(JsonObject o, string name)
|
||||||
|
{
|
||||||
|
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
|
||||||
|
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ public static class TagConfigEditorMap
|
|||||||
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||||
|
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
|
||||||
|
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
||||||
|
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||||
|
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ public static class TagConfigValidator
|
|||||||
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||||
|
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
||||||
|
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔
|
||||||
|
/// parser-input agreement: what <see cref="SqlTagConfigModel.ToJson"/> writes MUST parse cleanly through
|
||||||
|
/// <see cref="SqlEquipmentTagParser.TryParse"/> (the runtime factory's authoritative reader), and the
|
||||||
|
/// <c>model</c>/<c>type</c> enums MUST serialise as NAME strings — a numeric enum makes the parser reject
|
||||||
|
/// the tag (the "authors fine, never reads" defect class this guards).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlTagConfigModelTests
|
||||||
|
{
|
||||||
|
// ---- the plan's required assertion: string enums + unknown-key survival --------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
|
||||||
|
{
|
||||||
|
var m = SqlTagConfigModel.FromJson(
|
||||||
|
"""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
|
||||||
|
var json = m.ToJson();
|
||||||
|
json.ShouldContain("\"KeyValue\"");
|
||||||
|
json.ShouldContain("\"Float64\"");
|
||||||
|
json.ShouldContain("customX"); // unknown key survives load→save
|
||||||
|
json.ShouldNotContain("\"model\":0");
|
||||||
|
json.ShouldNotContain("\"type\":8"); // no numeric enum leaks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- defaults ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||||
|
{
|
||||||
|
var m = SqlTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
|
m.Model.ShouldBe(SqlTagModel.KeyValue);
|
||||||
|
m.Table.ShouldBeNull();
|
||||||
|
m.KeyColumn.ShouldBeNull();
|
||||||
|
m.KeyValue.ShouldBeNull();
|
||||||
|
m.ValueColumn.ShouldBeNull();
|
||||||
|
m.TimestampColumn.ShouldBeNull();
|
||||||
|
m.ColumnName.ShouldBeNull();
|
||||||
|
m.Type.ShouldBeNull();
|
||||||
|
m.RowSelector.WhereColumn.ShouldBeNull();
|
||||||
|
m.RowSelector.WhereValue.ShouldBeNull();
|
||||||
|
m.RowSelector.TopByTimestamp.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_of_default_model_omits_optional_keys()
|
||||||
|
{
|
||||||
|
var json = new SqlTagConfigModel { Table = "t" }.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("\"model\":\"KeyValue\"");
|
||||||
|
json.ShouldContain("\"table\":\"t\"");
|
||||||
|
json.ShouldNotContain("type"); // no type set ⇒ key omitted
|
||||||
|
json.ShouldNotContain("rowSelector"); // no selector ⇒ key omitted
|
||||||
|
json.ShouldNotContain("timestampColumn");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- round-trip fidelity through FromJson→ToJson→FromJson ----------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_preserves_keyValue_fields()
|
||||||
|
{
|
||||||
|
var m = new SqlTagConfigModel
|
||||||
|
{
|
||||||
|
Model = SqlTagModel.KeyValue,
|
||||||
|
Table = "dbo.TagValues",
|
||||||
|
KeyColumn = "TagName",
|
||||||
|
KeyValue = "Line1.Speed",
|
||||||
|
ValueColumn = "Val",
|
||||||
|
TimestampColumn = "Ts",
|
||||||
|
Type = DriverDataType.Float64,
|
||||||
|
};
|
||||||
|
|
||||||
|
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
|
||||||
|
|
||||||
|
m2.Model.ShouldBe(SqlTagModel.KeyValue);
|
||||||
|
m2.Table.ShouldBe("dbo.TagValues");
|
||||||
|
m2.KeyColumn.ShouldBe("TagName");
|
||||||
|
m2.KeyValue.ShouldBe("Line1.Speed");
|
||||||
|
m2.ValueColumn.ShouldBe("Val");
|
||||||
|
m2.TimestampColumn.ShouldBe("Ts");
|
||||||
|
m2.Type.ShouldBe(DriverDataType.Float64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_preserves_wideRow_wherePair_fields()
|
||||||
|
{
|
||||||
|
var m = new SqlTagConfigModel
|
||||||
|
{
|
||||||
|
Model = SqlTagModel.WideRow,
|
||||||
|
Table = "dbo.Wide",
|
||||||
|
ColumnName = "Speed",
|
||||||
|
RowSelector = new SqlRowSelectorModel { WhereColumn = "DeviceId", WhereValue = "42" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
|
||||||
|
|
||||||
|
m2.Model.ShouldBe(SqlTagModel.WideRow);
|
||||||
|
m2.Table.ShouldBe("dbo.Wide");
|
||||||
|
m2.ColumnName.ShouldBe("Speed");
|
||||||
|
m2.RowSelector.WhereColumn.ShouldBe("DeviceId");
|
||||||
|
m2.RowSelector.WhereValue.ShouldBe("42");
|
||||||
|
m2.RowSelector.TopByTimestamp.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_preserves_wideRow_topByTimestamp_fields()
|
||||||
|
{
|
||||||
|
var m = new SqlTagConfigModel
|
||||||
|
{
|
||||||
|
Model = SqlTagModel.WideRow,
|
||||||
|
Table = "dbo.Wide",
|
||||||
|
ColumnName = "Speed",
|
||||||
|
RowSelector = new SqlRowSelectorModel { TopByTimestamp = "Ts" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
|
||||||
|
|
||||||
|
m2.Model.ShouldBe(SqlTagModel.WideRow);
|
||||||
|
m2.ColumnName.ShouldBe("Speed");
|
||||||
|
m2.RowSelector.TopByTimestamp.ShouldBe("Ts");
|
||||||
|
m2.RowSelector.WhereColumn.ShouldBeNull();
|
||||||
|
m2.RowSelector.WhereValue.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_unknown_keys_top_level_and_nested()
|
||||||
|
{
|
||||||
|
var json = SqlTagConfigModel
|
||||||
|
.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts","futureKey":"keep"},"scaling":2.5}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("scaling"); // unknown top-level key survives
|
||||||
|
json.ShouldContain("2.5");
|
||||||
|
json.ShouldContain("futureKey"); // unknown nested (rowSelector) key survives
|
||||||
|
json.ShouldContain("keep");
|
||||||
|
json.ShouldContain("\"topByTimestamp\":\"Ts\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Validate() mirrors the parser's accept/reject boundary --------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_returns_null_for_valid_keyValue()
|
||||||
|
=> SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_returns_null_for_valid_wideRow_wherePair()
|
||||||
|
=> SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"whereColumn":"id","whereValue":"1"}}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_returns_null_for_valid_wideRow_topByTimestamp()
|
||||||
|
=> SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts"}}""")
|
||||||
|
.Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_missing_table()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_keyValue_missing_valueColumn()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_keyValue_missing_keyValue_field()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_wideRow_without_a_selector()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_wideRow_missing_columnName()
|
||||||
|
=> SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"t","rowSelector":{"topByTimestamp":"Ts"}}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_invalid_model_enum()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"Bogus","table":"t"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_invalid_type_enum()
|
||||||
|
=> SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_deferred_query_model()
|
||||||
|
=> SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""")
|
||||||
|
.Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
// ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader --------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")]
|
||||||
|
[InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")]
|
||||||
|
[InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")]
|
||||||
|
public void ToJson_output_parses_cleanly_through_SqlEquipmentTagParser(string authored)
|
||||||
|
{
|
||||||
|
// Round-trip the authored blob through the editor model, exactly as the TagModal save path does.
|
||||||
|
var roundTripped = SqlTagConfigModel.FromJson(authored).ToJson();
|
||||||
|
|
||||||
|
// The authoritative runtime reader must accept it — this is the editor-output ⇔ parser-input contract.
|
||||||
|
SqlEquipmentTagParser.TryParse(roundTripped, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
|
||||||
|
def.Name.ShouldBe("Plant/Sql/dev1/Speed");
|
||||||
|
def.Table.ShouldNotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_keyValue_output_parses_to_expected_definition_fields()
|
||||||
|
{
|
||||||
|
var json = SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
|
||||||
|
def.Model.ShouldBe(SqlTagModel.KeyValue);
|
||||||
|
def.Table.ShouldBe("dbo.TagValues");
|
||||||
|
def.KeyColumn.ShouldBe("TagName");
|
||||||
|
def.KeyValue.ShouldBe("Line1.Speed");
|
||||||
|
def.ValueColumn.ShouldBe("Val");
|
||||||
|
def.TimestampColumn.ShouldBe("Ts");
|
||||||
|
def.DeclaredType.ShouldBe(DriverDataType.Float64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_wideRow_wherePair_output_parses_to_expected_definition_fields()
|
||||||
|
{
|
||||||
|
var json = SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
|
||||||
|
def.Model.ShouldBe(SqlTagModel.WideRow);
|
||||||
|
def.ColumnName.ShouldBe("Speed");
|
||||||
|
def.RowSelectorColumn.ShouldBe("DeviceId");
|
||||||
|
def.RowSelectorValue.ShouldBe("42");
|
||||||
|
def.RowSelectorTopByTimestamp.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_wideRow_topByTimestamp_output_parses_to_expected_definition_fields()
|
||||||
|
{
|
||||||
|
var json = SqlTagConfigModel.FromJson(
|
||||||
|
"""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
|
||||||
|
def.Model.ShouldBe(SqlTagModel.WideRow);
|
||||||
|
def.ColumnName.ShouldBe("Speed");
|
||||||
|
def.RowSelectorTopByTimestamp.ShouldBe("Ts");
|
||||||
|
def.RowSelectorColumn.ShouldBeNull();
|
||||||
|
def.RowSelectorValue.ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user