diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
new file mode 100644
index 00000000..a0016cff
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
@@ -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
+
+
Sql tag editor — coming soon (Task 20).
+
+@code {
+ /// The tag's current TagConfig JSON.
+ [Parameter] public string? ConfigJson { get; set; }
+
+ /// Raised with the updated TagConfig JSON when the model changes.
+ [Parameter] public EventCallback ConfigJsonChanged { get; set; }
+
+ /// DriverType of the selected driver — accepted for dispatch uniformity.
+ [Parameter] public string DriverType { get; set; } = "";
+
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func 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);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs
new file mode 100644
index 00000000..4ac7c9c2
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs
@@ -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;
+
+///
+/// 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 : the model and
+/// type enums are written as their NAME strings (never numbers), and
+/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
+/// (top-level and inside rowSelector) across a load→save so a future field survives an edit.
+///
+public sealed class SqlTagConfigModel
+{
+ /// Tag→value mapping model. v1 accepts and
+ /// ; is deferred and rejected.
+ public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
+
+ /// The table or view to read (e.g. dbo.TagValues). Required for every model.
+ public string? Table { get; set; }
+
+ /// : the column matched against .
+ public string? KeyColumn { get; set; }
+
+ /// : this tag's key value (bound as a parameter at read time).
+ public string? KeyValue { get; set; }
+
+ /// : the column the value is read from.
+ public string? ValueColumn { get; set; }
+
+ /// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.
+ public string? TimestampColumn { get; set; }
+
+ /// : the column this tag reads from the selected row.
+ public string? ColumnName { get; set; }
+
+ /// : which row to read — a where-pair or newest-by-timestamp.
+ public SqlRowSelectorModel RowSelector { get; set; } = new();
+
+ /// Optional explicit OPC UA data type override (the blob's type field). null ⇒ the
+ /// driver infers the type from the result-set column metadata.
+ 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;
+
+ /// 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).
+ /// The raw TagConfig JSON string, or null for a new/empty config.
+ /// The populated .
+ public static SqlTagConfigModel FromJson(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ var (model, invalidModel) = ReadEnumStrict(o, "model");
+ var (type, invalidType) = ReadEnumStrict(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,
+ };
+ }
+
+ /// 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 rowSelector object is rebuilt from
+ /// (preserving its own unknown keys) and omitted entirely when empty.
+ /// The serialised TagConfig JSON string.
+ 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);
+ }
+
+ /// Validates the model against the exact accept/reject boundary of
+ /// . Returns an error message, or null when valid.
+ /// An error message describing the validation failure, or null when the model is valid.
+ 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(JsonObject o, string name)
+ where TEnum : struct, Enum
+ {
+ if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var s))
+ {
+ return (null, null);
+ }
+ return Enum.TryParse(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
+ }
+}
+
+///
+/// Typed working model for a tag's nested rowSelector object.
+/// Holds a where-pair (/) or a newest-by-timestamp
+/// selector (). Preserves unknown keys inside the selector across load→save.
+///
+public sealed class SqlRowSelectorModel
+{
+ /// The column that picks the row (matched against ).
+ public string? WhereColumn { get; set; }
+
+ /// The value 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.
+ public string? WhereValue { get; set; }
+
+ /// Newest-row selector: the timestamp column to order by (used when no where-pair is authored).
+ public string? TopByTimestamp { get; set; }
+
+ private JsonObject _bag = new();
+
+ /// Loads a selector model from the nested rowSelector JSON object (or a fresh empty model
+ /// when the object is absent), retaining any unknown keys.
+ /// The nested rowSelector object, or null when absent.
+ /// The populated .
+ 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,
+ };
+ }
+
+ /// Rebuilds the nested rowSelector JSON object from the exposed fields over the preserved
+ /// key bag, or returns null when the selector carries nothing (so the parent omits the key).
+ /// The rowSelector , or null when empty.
+ 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(out var s) ? s : v.ToJsonString();
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
index 0ec4501b..e10c9a50 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql;
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.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[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),
};
/// Returns the editor component type for a driver type, or null if none is registered.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
index f73e69d4..50ee89af 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -23,6 +24,8 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.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(),
};
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs
new file mode 100644
index 00000000..bfd0ad86
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs
@@ -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;
+
+///
+/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔
+/// parser-input agreement: what writes MUST parse cleanly through
+/// (the runtime factory's authoritative reader), and the
+/// model/type enums MUST serialise as NAME strings — a numeric enum makes the parser reject
+/// the tag (the "authors fine, never reads" defect class this guards).
+///
+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();
+ }
+}