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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user