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
@@ -1,6 +1,8 @@
using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -149,12 +151,34 @@ internal sealed class SqlBrowseSession : IBrowseSession
var columns = await ReadColumnsAsync(
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
var timestampColumn = PreferredTimestampColumn(columns);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
// that legitimately carries both "Value" and "value".
foreach (var column in columns)
{
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = reference.Schema,
[SqlBrowseAddressFields.Table] = reference.Table!,
[SqlBrowseAddressFields.ColumnName] = column.Name,
};
// The third part of a wide-row binding: WHICH row. Stated only when the catalog actually names
// a date/time column; the key is absent otherwise, never blank, so the mapper can tell "this
// table cannot be bound by browse" from "an older session said nothing".
if (timestampColumn is not null)
addressFields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn;
return
[
new AttributeInfo(
@@ -163,17 +187,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
IsArray: false,
SecurityClass: ReadOnlySecurityClass,
IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
AddressFields: addressFields),
];
}
@@ -245,6 +259,45 @@ internal sealed class SqlBrowseSession : IBrowseSession
return nodes;
}
/// <summary>
/// Picks the table's preferred <c>topByTimestamp</c> column — the one a browse-committed wide-row tag
/// orders by to read the newest row (Gitea #552) — or <see langword="null"/> when the table carries no
/// date/time column at all.
/// </summary>
/// <param name="columns">The table's columns, in the catalog order the dialect's column SQL returns.</param>
/// <returns>The chosen column name, or <see langword="null"/> when there is no candidate.</returns>
/// <remarks>
/// <para><b>Candidacy is decided by the column's declared TYPE, never by its name.</b> A name test
/// (<c>*_ts</c>, <c>*time*</c>, …) is a guess that reads as knowledge: it is locale-dependent, it
/// silently misses <c>Recorded</c>, and — the part that actually breaks data — it would happily order
/// a wide-row read by a <em>string</em> column called <c>timestamp</c>, binding the tag to whatever
/// row sorts last lexically. So the only input is
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
/// earliest-declared date/time column and is stable across calls, connections and servers — an
/// unordered "any candidate" would let two operators browsing the same table commit two differently
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
/// Nothing about the choice is recoverable only from the browse session.</para>
/// <para>The browsed column itself is deliberately NOT excluded: picking a table's timestamp column as
/// a tag reads the newest row's timestamp, which is a legitimate (and self-consistent) tag.</para>
/// </remarks>
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
{
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
}
return null;
}
/// <summary>
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The keys <c>SqlBrowseSession</c> states in a browsed column's <c>AttributeInfo.AddressFields</c>, and
/// that <c>RawBrowseCommitMapper</c> reads back when it builds the committed tag's <c>TagConfig</c>.
/// <para><b>Why a shared constant rather than a literal at each end.</b> A Sql tag's binding is a tuple
/// (table + column + which row), so — exactly as for MQTT's Sparkplug tuple and
/// <c>MqttTagConfigKeys</c> — the producing session states the parts and the mapper must never
/// re-infer them. Two independently spelled string literals agree by coincidence, and a disagreement is
/// silent: the mapper simply sees no field, and emits a config missing whatever the session was trying
/// to say. That is precisely how Gitea #552 shipped — a wide-row blob with no <c>rowSelector</c>, which
/// both <see cref="SqlEquipmentTagParser"/> and the authoring gate refuse.</para>
/// <para>These are the <em>browse channel's</em> names, not the tag blob's schema. Three of them happen
/// to match a <c>TagConfig</c> key because the mapper writes them straight through;
/// <see cref="TopByTimestamp"/> matches the key <em>nested inside</em> <c>rowSelector</c> — deliberately
/// not spelled <c>timestampColumn</c>, which is the blob's unrelated top-level source-timestamp
/// field.</para>
/// </summary>
public static class SqlBrowseAddressFields
{
/// <summary>The catalog schema owning the browsed column's table (e.g. <c>dbo</c>, <c>sales</c>).</summary>
public const string Schema = "schema";
/// <summary>The unqualified table (or view) name the browsed column belongs to.</summary>
public const string Table = "table";
/// <summary>The browsed column's own name — the wide-row tag's value column.</summary>
public const string ColumnName = "columnName";
/// <summary>
/// The table's preferred date/time-typed column, when it has one: the commit writes it as the
/// wide-row <c>rowSelector.topByTimestamp</c>, i.e. "read the newest row". Absent when the table
/// carries no date/time column at all, which is not a shape a browse can bind — see
/// <c>RawBrowseCommitMapper.DescribeUncommittableLeaf</c>.
/// </summary>
public const string TopByTimestamp = "topByTimestamp";
}
@@ -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();
}