merge: #552 — Sql browse-commit states a topByTimestamp row selector from column-type metadata (catalog-order tie-break); no-candidate leaves refused in words at the modal

This commit is contained in:
Joseph Doherty
2026-08-10 01:21:05 -04:00
8 changed files with 650 additions and 28 deletions
@@ -341,18 +341,43 @@ public static class RawBrowseCommitMapper
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks>
/// Only MQTT can fail this today, and only in one shape: a leaf whose attribute lookup returned
/// nothing, so no address was stated. Committing it anyway is the defect this whole seam exists
/// to close — a Sparkplug tuple cannot be reconstructed from the node id, so the row would deploy
/// clean and then report <c>BadNodeIdUnknown</c> forever with nothing to point at. Every
/// single-reference driver is unaffected: its address IS the node id.
/// <para>
/// Exactly two drivers can fail this, and both for the same underlying reason: their binding is a
/// TUPLE, so a leaf can be selectable in the tree and still not carry enough to bind. Every
/// single-reference driver is unaffected — its address IS the node id.
/// </para>
/// <para>
/// <b>MQTT</b>: a leaf whose attribute lookup returned nothing, so no address was stated. Committing
/// it anyway is the defect this whole seam exists to close — a Sparkplug tuple cannot be
/// reconstructed from the node id, so the row would deploy clean and then report
/// <c>BadNodeIdUnknown</c> forever with nothing to point at.
/// </para>
/// <para>
/// <b>Sql</b> (Gitea #552): a column of a table carrying no date/time column. A wide-row tag must say
/// which ROW it reads, and with no timestamp to order by the only remaining selector is a
/// <c>whereColumn</c>/<c>whereValue</c> pair — whose value is <em>data</em>, not schema, and therefore
/// is not something any browse can know. Refusing here rather than letting the import's own gate
/// refuse the blob is what makes the message name the table and the fix; the gate would only report
/// that a row selector is missing, on every selected column at once.
/// </para>
/// </remarks>
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary<string, string>? addressFields)
{
if (!Is(driverType, DriverTypeNames.Mqtt)) return null;
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
@@ -361,6 +386,31 @@ public static class RawBrowseCommitMapper
+ "the tag manually.";
}
/// <summary>
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
/// <c>null</c> when it can (Gitea #552).
/// </summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableSqlLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, SqlBrowseAddressFields.Table) is not { } table)
{
return $"'{browseName}' could not be committed: the browse session reported no Sql table for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
}
if (TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp) is not null) return null;
return $"'{browseName}' could not be committed: '{table}' has no date/time column, so which ROW the "
+ "tag reads cannot be derived from the schema. Author it in the tag editor with a "
+ "whereColumn/whereValue row selector — the value it matches on is data, not schema, so the "
+ "browser cannot supply it.";
}
/// <summary>
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling
/// back to <paramref name="fallback"/> — the driver model's own default — when the browser
@@ -438,17 +488,49 @@ public static class RawBrowseCommitMapper
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
/// </summary>
/// <param name="address">The leaf's browse node id — the column-name fallback only.</param>
/// <param name="addressFields">The structured address the browse session stated.</param>
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
/// <remarks>
/// <para>
/// <b>A wide-row tag needs three things, not two: the table, the column, and WHICH ROW</b>
/// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is
/// refused by <c>SqlEquipmentTagParser</c> and by <c>SqlTagConfigModel.Validate</c> alike — so every
/// browse-committed Sql tag was unreadable, first silently (<c>BadNodeIdUnknown</c> forever) and then,
/// once #551 put the gate on the import, loudly at commit.
/// </para>
/// <para>
/// <b>The row selector is READ from the session's stated fields, never inferred here</b> — same rule
/// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time
/// column by TYPE (see <c>SqlBrowseSession.PreferredTimestampColumn</c>); this mapper has only a
/// column name and a table name, from which any selector it invented would be a guess about data.
/// </para>
/// <para>
/// <b>When no candidate was stated the blob stays selector-less on purpose.</b> The alternatives are
/// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic
/// <c>{"address": …}</c> key throws away the table the session did know. Kept in the wide-row shape,
/// the blob opens in the typed editor with everything but the selector filled in — and the commit
/// itself does not reach here, because <see cref="DescribeUncommittableLeaf"/> refuses that leaf in
/// words first.
/// </para>
/// <para>
/// The blob's top-level <c>timestampColumn</c> (the SOURCE-timestamp column, which makes the driver
/// stamp values with the row's time instead of the poll time) is deliberately left unset: ordering by
/// a column is not a claim that it timestamps the value, and turning it on silently would change every
/// browse-committed tag's published SourceTimestamp.
/// </para>
/// </remarks>
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
{
if (addressFields is null
|| !addressFields.TryGetValue("table", out var table)
|| !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table)
|| string.IsNullOrWhiteSpace(table))
{
return WriteSingleKey("address", address);
}
addressFields.TryGetValue("schema", out var schema);
addressFields.TryGetValue("columnName", out var columnName);
addressFields.TryGetValue(SqlBrowseAddressFields.Schema, out var schema);
addressFields.TryGetValue(SqlBrowseAddressFields.ColumnName, out var columnName);
// Qualify the table with its schema when the schema is not the default, so a tag authored against
// "sales.Readings" cannot silently resolve to "dbo.Readings".
@@ -461,6 +543,13 @@ public static class RawBrowseCommitMapper
Model = SqlTagModel.WideRow,
Table = qualified,
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
// Null when the session stated none: SqlRowSelectorModel.ToJsonObject then returns null and the
// "rowSelector" key is omitted entirely, rather than written as an empty or blank-valued object.
RowSelector = new SqlRowSelectorModel
{
TopByTimestamp = TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp),
},
}.ToJson();
}