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
@@ -62,7 +62,7 @@ public static class SqlEquipmentTagParser
case SqlTagModel.KeyValue:
{
var keyColumn = ReadString(root, "keyColumn");
var keyValue = ReadString(root, "keyValue");
var keyValue = ReadScalar(root, "keyValue");
var valueColumn = ReadString(root, "valueColumn");
if (string.IsNullOrWhiteSpace(keyColumn)
|| string.IsNullOrWhiteSpace(valueColumn)
@@ -115,8 +115,17 @@ public static class SqlEquipmentTagParser
/// <summary>
/// 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 <c>whereValue</c> of either shape is captured
/// verbatim for later parameter binding. Null when absent or not a scalar.
/// boolean yields its raw token — so an authored value of any of those shapes is captured verbatim
/// for later parameter binding. Null when absent or not a scalar.
/// <para>
/// <b>Both value-bearing fields go through here</b>, <c>keyValue</c> as well as
/// <c>whereValue</c>, because there is no downstream difference between them: the reader BINDS
/// each as a <see cref="System.Data.Common.DbParameter"/> and matches each back as text —
/// <c>SqlPollReader.IndexByKey</c> stringifies the key cell precisely so an authored key can
/// face an INTEGER key column. Reading one of them string-only refused a tag the other shape
/// of the same authoring mistake was served under, and the deploy gate then reported the field
/// as MISSING when it was right there in the blob.
/// </para>
/// </summary>
private static string? ReadScalar(JsonElement o, string name)
{
@@ -129,8 +129,13 @@ public static class SqlTagDefinitionFactory
warnings.Add("Sql KeyValue tag has no 'keyColumn'.");
if (string.IsNullOrWhiteSpace(ReadString(root, "valueColumn")))
warnings.Add("Sql KeyValue tag has no 'valueColumn'.");
if (ReadString(root, "keyValue") is null)
warnings.Add("Sql KeyValue tag has no 'keyValue' (an empty string is allowed; an absent key is not).");
if (!HasScalar(root, "keyValue"))
{
warnings.Add(
"Sql KeyValue tag has no usable 'keyValue' — it must be a string, number or "
+ "boolean (an empty string is allowed; an absent key, or an object/array, is not).");
}
break;
case SqlTagModel.WideRow: