using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
///
/// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a
/// ready for . Under the v3
/// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's
/// driver reference is written into the driver-typed TagConfig as an ordinary address field
/// (nodeId/tagPath/symbolPath/address/attributeRef per driver) — never an
/// identity key. The address-field write reuses the same <Driver>TagConfigModel the typed tag
/// editors use, so a browse-committed tag round-trips through that editor unchanged.
///
public static class RawBrowseCommitMapper
{
///
/// Maps one selected browse leaf into an import row.
///
/// The owning device's driver type (a value).
/// The leaf's driver-side full reference (the BrowseNode.NodeId = the
/// captured DriverAttributeInfo.FullName) — becomes the driver-typed address field.
/// The leaf's browse name. Becomes the raw tag Name after
/// coerces it into a legal RawPath segment — a Sparkplug metric name
/// legitimately contains / and a Name may not (Gitea #509). The leaf's binding address is
/// unaffected: it travels separately into the TagConfig.
/// The leaf's name (from the browse
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).
/// The OPC-UA built-in type name to fall back to when
/// is null/unmappable.
/// The target TagGroup's device-relative path when committing under a group,
/// or null when committing at the device root. Prepended to any mirrored folder path.
/// The captured browse-folder nesting above the leaf (root→parent display
/// names); mirrored onto nested TagGroups only when is true.
/// When true, mirror as nested TagGroups.
/// The leaf's structured address (),
/// for a driver whose binding is a tuple rather than a single reference string; null for every driver
/// whose address is the itself.
/// The assembled import row.
public static RawTagImportRow MapLeaf(
string driverType,
string fullName,
string browseName,
string? driverDataType,
string defaultDataType,
string? groupPrefix,
IReadOnlyList? folderPath,
bool createGroups,
IReadOnlyDictionary? addressFields = null)
{
var dataType = MapDataType(driverDataType) ?? defaultDataType;
var tagConfig = BuildTagConfig(driverType, fullName, addressFields);
var name = ToRawSegment(browseName);
var mirrored = createGroups && folderPath is { Count: > 0 }
? string.Join(RawPaths.Separator, folderPath)
: null;
var groupPath = CombineGroupPath(groupPrefix, mirrored);
// Access baseline mirrors manual entry (Read); write-idempotent stays false (unknown at browse time);
// no poll-group (batching is authored later).
return new RawTagImportRow(
groupPath,
new RawTagInput(name, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
}
///
/// Coerces a browse leaf's name into a legal RawPath segment (Gitea #509): trims the ends and
/// replaces every with .
///
/// The leaf's browse name, as the driver reported it.
/// A -clean segment; blank only if the input was blank.
///
///
/// The Name and the address are two fields, and only the Name is constrained. A tag Name is
/// a RawPath segment, so it may not contain /; the driver-side reference it binds by has no
/// such rule and is written into the TagConfig untouched by .
/// Refusing the commit outright conflated the two — and it refused a metric the Sparkplug B spec
/// makes mandatory on every edge node (Node Control/Rebirth), so the one metric an
/// operator is most likely to browse for was the one that could not be committed.
///
///
/// This deliberately covers every driver, not just MQTT. The argument is about what a Name
/// is, not about who produced it: any browser whose leaf name carries a / hits the same
/// wall, and for all of them the binding address travels separately and survives intact.
///
///
/// The rule set is 's, not an invented one — empty,
/// an embedded separator, and leading/trailing whitespace are exactly what it rejects, so trimming
/// and replacing is total rather than a best-effort guess. RawBrowseCommitMapperTests pins
/// that by asserting the output validates, so a new rule there fails here instead of silently
/// letting an illegal Name through to the import.
///
///
/// Sanitising is not injective, so two distinct leaves can land on one Name — see
/// , which is why a caller must run that check rather than
/// trusting this in isolation.
///
///
public static string ToRawSegment(string? browseName)
{
var trimmed = (browseName ?? "").Trim();
return trimmed.Length == 0 ? "" : trimmed.Replace(RawPaths.Separator, SeparatorReplacement);
}
/// What substitutes for a separator inside a browse name.
private const char SeparatorReplacement = '_';
///
/// Names the browse leaves that would land on one tag Name within one
/// target group, or an empty list when the mapped rows are all distinct.
///
/// The ORIGINAL leaf names, in the same order as .
/// The mapped import rows.
/// One operator-facing message per colliding set; empty when there is nothing to refuse.
///
/// Sanitising maps many names onto one — A/B and A_B both become A_B — and a
/// silent collision is materially worse than the refusal #509 replaced: two distinct metrics would
/// become one tag bound to whichever address the import happened to keep. So the coercion is only
/// safe when paired with this check.
///
/// Scoped per target group, because the Name only has to be unique within its group — the same
/// metric name under two devices is not a collision.
///
///
/// The message names the specific pair rather than failing the batch anonymously. The commit
/// itself stays all-or-nothing, matching the CSV import's staged-review semantics: nothing is
/// written while any row is unresolvable.
///
///
public static IReadOnlyList DescribeNameCollisions(
IReadOnlyList browseNames,
IReadOnlyList rows)
{
ArgumentNullException.ThrowIfNull(browseNames);
ArgumentNullException.ThrowIfNull(rows);
return rows
.Select((row, i) => (row, original: i < browseNames.Count ? browseNames[i] : row.Tag.Name))
.GroupBy(x => (x.row.TagGroupPath ?? "", x.row.Tag.Name), TupleComparer)
.Where(g => g.Count() > 1)
.Select(g =>
$"'{g.Key.Item2}' would be the name of {g.Count()} different tags"
+ (string.IsNullOrEmpty(g.Key.Item1) ? "" : $" in group '{g.Key.Item1}'")
+ $": {string.Join(", ", g.Select(x => $"'{x.original}'"))}. "
+ "Their names differ only by characters that are not legal in a tag name. Commit them "
+ "separately, or author them by manual entry with distinct names.")
.ToList();
}
/// Ordinal comparer for the (group path, name) collision key — RawPath comparison is ordinal.
private static readonly IEqualityComparer<(string, string)> TupleComparer =
EqualityComparer<(string, string)>.Create(
(a, b) => RawPaths.Comparer.Equals(a.Item1, b.Item1) && RawPaths.Comparer.Equals(a.Item2, b.Item2),
x => HashCode.Combine(RawPaths.Comparer.GetHashCode(x.Item1), RawPaths.Comparer.GetHashCode(x.Item2)));
///
/// Maps a enum NAME (as reported by the browse attribute side-channel) to the
/// OPC-UA built-in type name a carries. Mirrors
/// DiscoveredNodeMapper.ToBuiltinTypeString: most names pass through, Float32/Float64
/// become Float/Double, and Reference (a Galaxy attribute ref) carries as String.
/// Returns null when the input is null/blank or not a known driver data type (caller supplies a default).
///
/// The name, or null/blank.
/// The OPC-UA built-in type name, or null when unmappable.
public static string? MapDataType(string? driverDataType)
{
if (string.IsNullOrWhiteSpace(driverDataType)
|| !Enum.TryParse(driverDataType, ignoreCase: true, out var dt))
return null;
return dt switch
{
DriverDataType.Boolean => "Boolean",
DriverDataType.Int16 => "Int16",
DriverDataType.Int32 => "Int32",
DriverDataType.Int64 => "Int64",
DriverDataType.UInt16 => "UInt16",
DriverDataType.UInt32 => "UInt32",
DriverDataType.UInt64 => "UInt64",
DriverDataType.Float32 => "Float",
DriverDataType.Float64 => "Double",
DriverDataType.String => "String",
DriverDataType.DateTime => "DateTime",
DriverDataType.Reference => "String",
_ => null,
};
}
///
/// Builds the driver-typed TagConfig JSON for a browse-committed tag: the driver's address field
/// from , plus — for a driver that binds by its OWN data-type enum — the
/// native type the browse leaf stated (see ). Every other field stays at
/// its model default, for the operator to author afterwards. Reuses the
/// <Driver>TagConfigModel for driver types that have a typed editor; the model-less Galaxy
/// driver gets the canonical camelCase attributeRef key directly.
///
/// The data type is not optional decoration for AB CIP / TwinCAT / FOCAS — their tag-definition
/// factories decode the wire bytes by this field, not by the raw tag row's OPC UA DataType. A
/// committed leaf left at the model default therefore reads real data through the wrong decoder and
/// publishes a plausible wrong number rather than an error (Gitea #536).
///
///
/// MQTT is the one driver whose address is not a single string — a Sparkplug tag binds by a
/// (group, edgeNode, device?, metric) tuple — so it is built from the browse session's stated
/// instead. See .
///
///
/// The owning device's driver type.
/// The leaf's driver-side full reference to write into the address field.
/// The leaf's structured address, when the driver binds by a tuple —
/// see . Ignored by every single-reference driver.
/// The serialised driver-typed TagConfig JSON.
public static string BuildTagConfig(
string driverType,
string fullName,
IReadOnlyDictionary? addressFields = null)
{
var address = fullName ?? "";
if (Is(driverType, DriverTypeNames.Mqtt))
return BuildMqttTagConfig(address, addressFields);
if (Is(driverType, DriverTypeNames.OpcUaClient))
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
if (Is(driverType, DriverTypeNames.AbCip))
return new AbCipTagConfigModel
{
TagPath = address,
DataType = NativeType(addressFields, AbCipDataType.DInt),
}.ToJson();
if (Is(driverType, DriverTypeNames.AbLegacy))
return new AbLegacyTagConfigModel { Address = address }.ToJson();
if (Is(driverType, DriverTypeNames.TwinCAT))
return new TwinCATTagConfigModel
{
SymbolPath = address,
DataType = NativeType(addressFields, TwinCATDataType.DInt),
}.ToJson();
if (Is(driverType, DriverTypeNames.FOCAS))
return new FocasTagConfigModel
{
Address = address,
DataType = NativeType(addressFields, FocasDataType.Int32),
}.ToJson();
if (Is(driverType, DriverTypeNames.S7))
return new S7TagConfigModel { Address = address }.ToJson();
// MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
// editor's canonical spelling is fullName — committing under the generic key below would open
// in that editor with an EMPTY id field and blank it on save.
if (Is(driverType, DriverTypeNames.MTConnect))
return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
// Sql editor does not read: it would open with empty fields and blank them on save — the exact
// failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable
// (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue.
if (Is(driverType, DriverTypeNames.Sql))
return BuildSqlTagConfig(address, addressFields);
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
// "address" key so nothing is lost. RawBrowseCommitMapperParityTests enumerates DriverTypeNames.All
// and holds this line to two claims rather than to the assertion the comment used to make:
// - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the
// real registration, not a list — must NOT reach here; and
// - any type that does reach here must be named in that test's GenericKeyExemptions with a reason,
// and every name on that list must still reach here (Gitea #558) — an exemption for a driver
// that has since gained a branch above is a stale claim, and a silently inert one.
// The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs
// the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather
// than mechanically — see the test's remarks for the residual trust that leaves (Gitea #555).
return WriteSingleKey("address", address);
}
///
/// Builds the TagConfig for a browse-committed MQTT leaf, whose address is a
/// descriptor rather than a single reference string: topic in Plain mode, and the
/// groupId/edgeNodeId/deviceId?/metricName tuple in Sparkplug B mode.
///
/// The leaf's browse node id — the Plain-mode fallback address only.
/// The structured address the browse session stated.
/// The serialised TagConfig JSON.
///
///
/// The address is read from , never parsed out of
/// . A Sparkplug browse node id is
/// {group}/{node}[/{device}]::{metric}, and a metric name legitimately contains /
/// (Node Control/Rebirth) — so splitting the id cannot recover the tuple in general, and a
/// mapper that guessed would silently bind the wrong metric. The session that decoded the birth
/// already holds the decomposition and hands it over; this method only picks the keys it knows.
///
///
/// Plain vs Sparkplug is likewise stated, not inferred — the driver type reaching
/// here is just Mqtt, and the mode lives on the driver config, not on the tag. The
/// producing session knows its own mode and says so by which keys it emits: a
/// metricName ⇒ Sparkplug, a topic ⇒ Plain. (The blob deliberately carries no
/// mode key: MqttTagDefinitionFactory takes the shape from the driver's mode and
/// would ignore one, and MqttTagConfigModel re-infers it from these same keys.)
///
///
/// Keys are whitelisted, not splatted: these values came off a broker, and everything
/// beyond the address (payload format, JSONPath, QoS, dataType) stays at the driver's own
/// defaults for the operator to author afterwards. A leaf with no stated address at all is
/// rejected before this is reached — see .
///
///
private static string BuildMqttTagConfig(string fullName, IReadOnlyDictionary? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is { } metricName)
{
var o = new JsonObject
{
[MqttTagConfigKeys.GroupId] = TryGetField(addressFields, MqttTagConfigKeys.GroupId) ?? "",
[MqttTagConfigKeys.EdgeNodeId] = TryGetField(addressFields, MqttTagConfigKeys.EdgeNodeId) ?? "",
[MqttTagConfigKeys.MetricName] = metricName,
};
// Absent, not blank, for a node-level metric — the two describe the same scope to the
// factory, but only the absent form says "this metric has no device" to a reader.
if (TryGetField(addressFields, MqttTagConfigKeys.DeviceId) is { } deviceId)
o[MqttTagConfigKeys.DeviceId] = deviceId;
return o.ToJsonString();
}
// Plain: the session states the topic; the node id is the same value and is the fallback for a
// session that stated nothing (a shape DescribeUncommittableLeaf refuses upstream).
return WriteSingleKey(
MqttTagConfigKeys.Topic,
TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName);
}
///
/// Describes why a selected browse leaf cannot be committed as a working tag, or null when it
/// can. The AdminUI calls this before mapping so an unbindable selection fails at commit, in
/// words.
///
/// The owning device's driver type.
/// The leaf's browse name, for the message.
/// The structured address the browse session stated, if any.
/// The operator-facing reason, or null when the leaf is committable.
///
///
/// 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.
///
///
/// MQTT: 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
/// BadNodeIdUnknown forever with nothing to point at.
///
///
/// Sql (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
/// whereColumn/whereValue pair — whose value is data, 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.
///
///
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary? addressFields)
{
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
/// Describes an MQTT leaf that states no address at all, or null when it states one.
/// The leaf's browse name, for the message.
/// The structured address the browse session stated, if any.
/// The operator-facing reason, or null.
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
}
///
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
/// null when it can (Gitea #552).
///
/// The leaf's browse name, for the message.
/// The structured address the browse session stated, if any.
/// The operator-facing reason, or null.
private static string? DescribeUncommittableSqlLeaf(
string browseName, IReadOnlyDictionary? 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.";
}
///
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling
/// back to — the driver model's own default — when the browser
/// stated none or stated one this driver's enum does not name.
///
/// The driver's own data-type enum.
/// The leaf's stated address fields.
/// The model default to keep when no usable native type was stated.
/// The parsed native type, or .
///
/// The type is READ, never derived (Gitea #536). A commit used to leave this at the
/// model default, so every browsed AB CIP leaf — a REAL, a BOOL, a STRING — was written as
/// DInt and the driver decoded four bytes of a float as an integer: a plausible
/// number, no error, wrong value. Deriving it from the leaf's driver-AGNOSTIC
/// DriverDataType instead would fix the REAL and still get the integers wrong —
/// SInt, Int and DInt all collapse into Int32 on the way out, so
/// a 16-bit INT would come back as a 32-bit DINT. The producing driver knows its own type
/// and states it; see CapturedTreeBrowseSession.NativeTypeFields.
///
/// Case-insensitive because the key travels as an enum NAME, and a driver is free to spell
/// it however its own enum does. An unparseable value keeps the default rather than failing
/// the commit: the address — the part that cannot be re-derived later — is still correct,
/// and the operator can fix the type in the typed editor.
///
///
/// A numeric string is refused before parsing. Enum.TryParse accepts "9"
/// for any enum and hands back whatever member sits at ordinal 9 — which Enum.IsDefined
/// then happily confirms — so a driver that stamped a number would bind a tag to a type nobody
/// named. Same trap MTConnectTagConfigModel documents for its own dataType.
///
///
private static TEnum NativeType(IReadOnlyDictionary? addressFields, TEnum fallback)
where TEnum : struct, Enum
=> TryGetField(addressFields, "dataType") is { Length: > 0 } raw
&& !char.IsAsciiDigit(raw[0]) && raw[0] is not ('-' or '+')
&& Enum.TryParse(raw, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed)
? parsed
: fallback;
/// Reads one non-blank address field, or null when it is absent or blank.
/// The stated address fields, or null.
/// The field to read.
/// The trimmed value, or null.
private static string? TryGetField(IReadOnlyDictionary? fields, string key)
=> fields is not null && fields.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v)
? v.Trim()
: null;
///
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
/// separator. Returns null when both are absent (⇒ commit at the device root).
///
/// The target TagGroup's device-relative path, or null/blank for the device root.
/// The mirrored folder sub-path, or null/blank.
/// The combined device-relative group path, or null for the device root.
public static string? CombineGroupPath(string? prefix, string? suffix)
{
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
suffix = string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim();
if (prefix is null) return suffix;
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
}
private static bool Is(string driverType, string name)
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
///
/// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to
/// SqlTagModel.WideRow — the browse tree is schema → table → column, which is precisely the
/// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived
/// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the
/// typed editor.
/// 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.
///
/// The leaf's browse node id — the column-name fallback only.
/// The structured address the browse session stated.
/// The serialised TagConfig JSON.
///
///
/// A wide-row tag needs three things, not two: the table, the column, and WHICH ROW
/// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is
/// refused by SqlEquipmentTagParser and by SqlTagConfigModel.Validate alike — so every
/// browse-committed Sql tag was unreadable, first silently (BadNodeIdUnknown forever) and then,
/// once #551 put the gate on the import, loudly at commit.
///
///
/// The row selector is READ from the session's stated fields, never inferred here — same rule
/// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time
/// column by TYPE (see SqlBrowseSession.PreferredTimestampColumn); this mapper has only a
/// column name and a table name, from which any selector it invented would be a guess about data.
///
///
/// When no candidate was stated the blob stays selector-less on purpose. The alternatives are
/// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic
/// {"address": …} 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 refuses that leaf in
/// words first.
///
///
/// The blob's top-level timestampColumn (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.
///
///
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? addressFields)
{
if (addressFields is null
|| !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table)
|| string.IsNullOrWhiteSpace(table))
{
return WriteSingleKey("address", address);
}
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".
var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase)
? $"{schema}.{table}"
: table;
return new SqlTagConfigModel
{
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();
}
private static string WriteSingleKey(string key, string value)
{
var o = new JsonObject { [key] = value };
return o.ToJsonString();
}
}