fix(sql): read keyValue with the same scalar read as whereValue (Driver.Sql.Contracts-001)

The two value-bearing fields were read asymmetrically and nothing said why.
whereValue went through ReadScalar (string/number/boolean — the design doc's own
wide-row example authors it as the number 7); keyValue went through the string-only
ReadString, so a numerically-authored key refused the tag outright. Nothing downstream
justified the split: both are BOUND as DbParameters and both are matched back as text —
SqlPollReader.IndexByKey stringifies the key cell precisely so an authored key can face
an INTEGER key column.

The refusal was also mis-explained. Inspect branched on ReadString(root, "keyValue") is
null, so a numerically-authored key drew "Sql KeyValue tag has no 'keyValue'" — telling
the operator the field is missing when it is right there in the blob, the #549 shape of
an authoring surface disagreeing with the runtime about WHY.

keyValue now uses ReadScalar; Inspect mirrors it with HasScalar and says what a usable
key is; SqlTagConfigModel loads it with the shared TagConfigJson.GetScalarText (which
also replaces SqlRowSelectorModel's private copy), so the editor is not left stricter
than the parser. An object/array/null key is still refused. docs/drivers/Sql.md updated.

RED first: 4 failing in Driver.Sql.Tests.
This commit is contained in:
Joseph Doherty
2026-08-10 08:07:15 -04:00
parent 8d4ca5489a
commit 678aa3aa53
7 changed files with 123 additions and 18 deletions
@@ -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(
@@ -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"}""")