merge: SQL contracts review findings resolved (Driver.Sql.Contracts-001 scalar keyValue, -002 fleet-wide numeric-enum rejection)

This commit is contained in:
Joseph Doherty
2026-08-10 08:10:36 -04:00
12 changed files with 332 additions and 34 deletions
@@ -35,6 +35,79 @@ public sealed class TagConfigJsonTests
v.ShouldBe(expectedValue);
}
// ---- A numeric spelling is not a member NAME (Driver.Sql.Contracts-002) ----
/// <summary>
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts a numeric STRING for any
/// enum — <c>"1"</c> hands back whatever member sits at ordinal 1, and an out-of-range <c>"99"</c>
/// parses just as happily — so a TagConfig carrying a numerically-serialised enum (the repo-wide
/// authoring trap: a page that serialises enums as numbers into a string-typed field) used to read
/// <see cref="JsonEnumRead.Valid"/> at every one of the six driver parsers. The tag was then served
/// under a data type nobody named, publishing a confident <c>Good</c> — the exact failure R2-11's
/// strict read exists to prevent. A digit- or sign-leading value is refused BEFORE the parse.
/// </summary>
[Theory]
[InlineData("\"1\"")] // in range — parses today, and IsDefined would confirm it
[InlineData("\"99\"")] // out of range — parses today too
[InlineData("\"0\"")]
[InlineData("\"-1\"")] // sign-leading
[InlineData("\"+1\"")]
[InlineData("\" 2 \"")] // Enum.TryParse trims, so the guard must judge the TRIMMED text
public void TryReadEnum_aNumericSpelling_isInvalid_notAMemberName(string token)
{
TagConfigJson.TryReadEnum<Sample>(Root($"{{\"k\":{token}}}"), "k", out var v)
.ShouldBe(JsonEnumRead.Invalid);
v.ShouldBe(default(Sample));
}
/// <summary>
/// The <see cref="Enum.IsDefined{TEnum}(TEnum)"/> half of the guard, which the digit-leading check
/// cannot cover: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> also accepts a
/// COMMA-SEPARATED list for a non-<c>[Flags]</c> enum and bitwise-ORs the members, so
/// <c>"Beta, Gamma"</c> parses to <c>3</c> — a value no member declares. Deleting the
/// <c>IsDefined</c> check turns this case green again.
/// </summary>
[Fact]
public void TryReadEnum_aCombinationThatNoMemberDeclares_isInvalid()
{
TagConfigJson.TryReadEnum<Sample>(Root("{\"k\":\"Beta, Gamma\"}"), "k", out var v)
.ShouldBe(JsonEnumRead.Invalid);
v.ShouldBe(default(Sample));
}
/// <summary>
/// Falsifiability control for the two guards above: a guard that refused everything would satisfy
/// them all. Member NAMES — the only spelling a TagConfig may carry — keep parsing, whatever their
/// case and surrounding whitespace.
/// </summary>
[Theory]
[InlineData("Alpha", Sample.Alpha)]
[InlineData("beta", Sample.Beta)]
[InlineData("GAMMA", Sample.Gamma)]
[InlineData(" Gamma ", Sample.Gamma)]
public void ClassifyEnumText_stillAcceptsEveryMemberName(string text, Sample expected)
{
TagConfigJson.ClassifyEnumText<Sample>(text, out var v).ShouldBe(JsonEnumRead.Valid);
v.ShouldBe(expected);
}
/// <summary>
/// A numeric spelling is refused with the SAME wording every other rejection carries — naming the
/// field, the offending value and the members that are allowed — so the operator is told what to
/// write instead of being told nothing.
/// </summary>
[Fact]
public void DescribeInvalidEnum_aNumericSpelling_namesTheAllowedMembers()
{
var msg = TagConfigJson.DescribeInvalidEnum<Sample>(Root("{\"dataType\":\"8\"}"), "dataType");
msg.ShouldNotBeNull();
msg.ShouldContain("'8'");
msg.ShouldContain("dataType");
msg.ShouldContain("Alpha");
msg.ShouldContain("Beta");
msg.ShouldContain("Gamma");
}
[Theory]
[InlineData("{\"k\":\"Gamma\"}", Sample.Gamma)] // valid → parsed
[InlineData("{\"k\":\"typo\"}", Sample.Alpha)] // invalid → fallback
@@ -50,6 +123,8 @@ public sealed class TagConfigJsonTests
[InlineData("{}", true, Sample.Alpha)] // absent → fallback, ok
[InlineData("{\"k\":42}", true, Sample.Alpha)] // non-string → Absent → fallback, ok
[InlineData("{\"k\":\"typo\"}", false, default(Sample))] // invalid (typo) → REJECT (false)
[InlineData("{\"k\":\"1\"}", false, default(Sample))] // numeric spelling → REJECT (Driver.Sql.Contracts-002)
[InlineData("{\"k\":\"99\"}", false, default(Sample))] // …in range or out of it
public void TryReadEnumStrict_rejects_only_invalid(string json, bool expectedOk, Sample expectedValue)
{
var ok = TagConfigJson.TryReadEnumStrict(Root(json), "k", Sample.Alpha, out var v);
@@ -28,6 +28,55 @@ public class SqlEquipmentTagParserTests
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
/// <summary>
/// Driver.Sql.Contracts-001. The two value-bearing fields are read the same way: a JSON string,
/// number or boolean is captured as text, because both end up BOUND as a <c>DbParameter</c> and
/// both are matched back as text — <c>SqlPollReader.IndexByKey</c> stringifies the key cell
/// precisely so an authored key can face an INTEGER key column. <c>keyValue</c> used to go through
/// the narrower string-only read, so a numerically-authored key refused the tag outright while the
/// identically-bound <c>whereValue</c> sailed through as the design doc's own example authors it.
/// </summary>
[Theory]
[InlineData("7", "7")]
[InlineData("-12.5", "-12.5")]
[InlineData("true", "true")]
public void TryParse_KeyValue_aNonStringScalarKey_isCapturedAsTextLikeWhereValue(string token, string expected)
{
var json =
$$"""{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"station_id","keyValue":{{token}},"valueColumn":"num_value"}""";
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
def.KeyValue.ShouldBe(expected);
}
/// <summary>
/// …and a non-scalar key is still refused — the widening is to the scalar shapes a parameter can
/// carry, not to anything at all.
/// </summary>
[Theory]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":{"a":1},"valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":[1],"valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":null,"valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","valueColumn":"c"}""")]
public void TryParse_KeyValue_aNonScalarOrAbsentKey_rejectsTag(string json)
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
/// <summary>
/// The <c>Inspect</c> half of the same rule. A numerically-authored key is served, so the deploy
/// gate must say nothing about it; an absent one is refused, and the gate must still name it.
/// </summary>
[Fact]
public void Inspect_aNumericallyAuthoredKeyValue_drawsNoWarning()
=> SqlTagDefinitionFactory.Inspect(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":7,"valueColumn":"c"}""")
.ShouldBeEmpty();
/// <summary>Falsifiability control for the assertion above: a genuinely absent key still warns.</summary>
[Fact]
public void Inspect_anAbsentKeyValue_stillNamesTheField()
=> SqlTagDefinitionFactory.Inspect(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","valueColumn":"c"}""")
.ShouldContain(w => w.Contains("keyValue"));
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
@@ -196,4 +245,31 @@ public class SqlEquipmentTagParserTests
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""",
"raw", out _).ShouldBeFalse();
/// <summary>
/// Driver.Sql.Contracts-002. A numerically-spelled enum is not a member name, and the strict read
/// must refuse it exactly as it refuses a typo. It used to parse — <c>"99"</c> gave
/// <c>(DriverDataType)99</c>, a declared type nothing can map, and <c>SqlPollReader.TryCoerce</c>
/// then fell through its switch to the <c>String</c> arm and published a stringified cell under it.
/// </summary>
[Theory]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"99"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"8"}""")]
// "1" is SqlTagModel.WideRow's ordinal — an otherwise-complete WideRow blob, so the rejection can only
// come from the enum read and not from a missing field.
[InlineData("""{"driver":"Sql","model":"1","table":"T","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
public void TryParse_aNumericallySpelledEnum_rejectsTag(string json)
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
/// <summary>
/// …and the deploy gate explains it in the same words every other invalid enum draws, rather than
/// falling through to the backstop warning that names no field at all.
/// </summary>
[Fact]
public void Inspect_aNumericallySpelledType_namesTheFieldAndTheValidMembers()
{
var warnings = SqlTagDefinitionFactory.Inspect(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"99"}""");
warnings.ShouldContain(w => w.Contains("'99'") && w.Contains("type") && w.Contains(nameof(DriverDataType.Float64)));
}
}
@@ -184,6 +184,32 @@ public sealed class SqlTagConfigModelTests
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""")
.Validate().ShouldNotBeNull();
/// <summary>
/// Driver.Sql.Contracts-001. The parser reads <c>keyValue</c> with the same scalar read it uses for
/// <c>whereValue</c>, so a numerically-authored key is SERVED — the editor must load it (rather
/// than seeing an absent key), let it save, and normalise it to a string on the way out, exactly as
/// it already does for <c>whereValue</c>. An editor that refused it would be stricter than the
/// runtime, which is the drift this parity suite exists to catch.
/// </summary>
[Fact]
public void A_numerically_authored_keyValue_loads_validates_and_normalises_to_a_string()
{
const string json =
"""{"model":"KeyValue","table":"t","keyColumn":"station_id","keyValue":7,"valueColumn":"c"}""";
SqlEquipmentTagParser.TryParse(json, "raw", out var served).ShouldBeTrue(
"the runtime must serve this blob, or the authoring parity below proves nothing.");
served.KeyValue.ShouldBe("7");
var m = SqlTagConfigModel.FromJson(json);
m.KeyValue.ShouldBe("7");
m.Validate().ShouldBeNull();
var saved = m.ToJson();
saved.ShouldContain("\"keyValue\":\"7\"");
SqlEquipmentTagParser.TryParse(saved, "raw", out var afterSave).ShouldBeTrue();
afterSave.KeyValue.ShouldBe("7");
}
[Fact]
public void Validate_rejects_wideRow_without_a_selector()
=> SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""")
@@ -220,6 +220,37 @@ public sealed class TagConfigAuthoringDeployParityTests
+ "import or API is unchecked — the exact G-7 asymmetry.");
}
/// <summary>
/// Driver.Sql.Contracts-002, fleet-wide. A numerically-SPELLED enum (<c>"1"</c>, not the JSON
/// number <c>1</c>) used to satisfy <c>Enum.TryParse</c> at every driver, so both doors accepted it
/// and the tag was served under whatever member sat at that ordinal — or, out of range, under a
/// value no member declares. The shared <c>TagConfigJson.ClassifyEnumText</c> now calls it what it
/// is, and because BOTH gates ask that one method, they moved together: the fix is only real if
/// each driver refuses it at the modal AND at the deploy gate.
/// </summary>
[Theory]
// Complete but for the enum spelling, so the refusal can only come from the enum read: "1" is
// SqlTagModel.WideRow's ordinal, and every WideRow-required field is present.
[InlineData(DriverTypeNames.Sql, """{"model":"1","table":"dbo.T","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
[InlineData(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"8"}""")]
[InlineData(DriverTypeNames.Modbus, """{"region":"1","address":1}""")]
[InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"99"}""")]
[InlineData(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"1"}""")]
[InlineData(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed","dataType":"1"}""")]
[InlineData(DriverTypeNames.AbLegacy, """{"address":"N7:0","dataType":"1"}""")]
[InlineData(DriverTypeNames.TwinCAT, """{"symbolPath":"MAIN.bStart","dataType":"1"}""")]
[InlineData(DriverTypeNames.FOCAS, """{"address":"R100","dataType":"1"}""")]
[InlineData(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","payloadFormat":"1"}""")]
[InlineData(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","dataType":"8"}""")]
public void A_numerically_spelled_enum_is_refused_by_both_gates(string driverType, string tagConfig)
{
TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty(
$"{driverType}: the modal would let an operator save an enum spelling the driver refuses.");
EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldNotBeEmpty(
$"{driverType}: the deploy gate accepted a numeric enum spelling, so a CSV-imported or "
+ "API-authored tag binds to a type nobody named.");
}
// -------------------------------------------------------------------------------------------------
// Gitea #550 — MTConnect's second spelling of the coercion type. The assertions below drive the REAL
// runtime reader (MTConnectTagDefinitionFactory.TryFromTagConfig) rather than the deploy gate alone,