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
@@ -25,7 +25,9 @@ public sealed class SqlTagConfigModel
/// <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>
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (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? KeyValue { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
@@ -72,7 +74,7 @@ public sealed class SqlTagConfigModel
Model = model ?? SqlTagModel.KeyValue,
Table = TagConfigJson.GetString(o, "table"),
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
KeyValue = TagConfigJson.GetString(o, "keyValue"),
KeyValue = TagConfigJson.GetScalarText(o, "keyValue"),
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
@@ -204,7 +206,7 @@ public sealed class SqlRowSelectorModel
return new SqlRowSelectorModel
{
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
WhereValue = ReadScalarText(bag, "whereValue"),
WhereValue = TagConfigJson.GetScalarText(bag, "whereValue"),
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
_bag = bag,
};
@@ -221,12 +223,4 @@ public sealed class SqlRowSelectorModel
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();
}
}
@@ -34,6 +34,23 @@ public static class TagConfigJson
public static string? GetString(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
/// <summary>
/// Reads a scalar as its textual form — a JSON string yields its contents, a number or boolean
/// yields its raw token; <c>null</c> when absent, JSON <c>null</c>, or a non-scalar node. Mirrors
/// the runtime parsers' <c>ReadScalar</c>, for the value-bearing fields a driver BINDS as a
/// parameter and so may legitimately author as a number (a Sql <c>keyValue</c> facing an INTEGER
/// key column, say). Reading such a field with <see cref="GetString"/> makes the editor refuse a
/// blob the driver serves.
/// </summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The scalar's text, or <c>null</c> when absent or not a scalar.</returns>
public static string? GetScalarText(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();
}
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>