Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
T
Joseph Doherty a68593e714 feat(sql): named-query tag model — read-only SELECTs declared on the driver (lmxopcua#753)
SqlTagModel.Query (design §5.4) is now served end to end instead of refused.
The driver config declares `queries` (name -> sql, params, maxRows); a tag names
the query, the result `column` it reads and optionally a `rowKey` picking its row.

- Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect
  explains each refusal. Table is not read for this model.
- SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword
  outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by
  the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the
  form refuses what the driver refuses. One bad entry fails the driver.
- Parameters are always bound; the factory refuses a declared parameter whose
  dialect marker the statement never uses.
- Reader: one execution per query per poll, inside a transaction that is always
  rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData,
  ambiguous row / missing column / over maxRows -> BadConfigurationError.
- Catalog gate passes Query tags through (nothing is quoted into SQL); a
  Query-only driver skips the catalog load. A tag naming an undeclared query is
  skipped with the reason on /hosts.
- AdminUI: typed editor offers Query (query/column/rowKey); driver form edits
  `queries` as JSON and keeps the last valid set on a typo.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00

80 lines
4.4 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The typed form of a <c>Sql</c> driver instance's configuration — what
/// <see cref="SqlDriverConfigDto"/> becomes once the factory has applied its defaults (design §5.1).
/// Mirrors <c>ModbusDriverOptions</c>: the driver instance is constructed with these, and its
/// <see cref="SqlDriver.InitializeAsync"/> serves them rather than re-parsing the config JSON.
/// <para><b>No connection string here.</b> The resolved connection string is a separate constructor
/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.</para>
/// </summary>
public sealed class SqlDriverOptions
{
/// <summary>
/// The authored raw tags this driver serves. The deploy artifact hands each authored raw <c>Tag</c>
/// as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob); the driver maps
/// each through <see cref="SqlEquipmentTagParser.TryParse"/> into its RawPath → definition table.
/// A SQL source has no tag-discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Poll interval used when a subscriber does not ask for one (design §4: <c>defaultPollInterval</c>,
/// default 5 s). A subscriber that names an interval gets that interval, floored by
/// <see cref="PollGroupEngine.DefaultMinInterval"/>.
/// </summary>
public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
/// publishes <see cref="SqlStatusCodes.BadTimeout"/> for that group's tags.
/// </summary>
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
/// <summary>
/// The ADO.NET <c>CommandTimeout</c> server-side backstop (default 10 s), also the bound on the
/// Initialize-time liveness check.
/// <para>Authoring must keep <see cref="OperationTimeout"/> strictly greater than this (design §8.3);
/// inverted, the client-side abort always fires first and masks the backstop. <b>That rule is
/// enforced by config validation in the factory, not here</b> — the reader deliberately stays usable
/// with the order inverted so the frozen-database tests can prove the client-side bound fires.</para>
/// </summary>
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>Cap on concurrently executing group queries — and therefore on concurrently open connections.</summary>
public int MaxConcurrentGroups { get; init; } = 4;
/// <summary>
/// When <see langword="true"/>, a present row whose value cell is NULL publishes
/// <see cref="SqlStatusCodes.Bad"/> instead of the default <see cref="SqlStatusCodes.Uncertain"/>.
/// It never governs an absent row, which is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </summary>
public bool NullIsBad { get; init; }
/// <summary>
/// The validated named queries (design §5.4) a <see cref="SqlTagModel.Query"/> tag reads by name, keyed
/// ordinally. Empty when the config declares none.
/// </summary>
public IReadOnlyDictionary<string, SqlNamedQuery> Queries { get; init; } =
new Dictionary<string, SqlNamedQuery>(StringComparer.Ordinal);
}
/// <summary>
/// One validated named query — what <see cref="SqlNamedQueryDto"/> becomes once the factory has checked it
/// (<see cref="SqlNamedQueryRules.Describe"/> plus the dialect's marker check).
/// </summary>
/// <param name="Name">The <c>queries</c> key tags reference.</param>
/// <param name="Sql">The normalised single read-only statement (no trailing terminator).</param>
/// <param name="ParameterNames">The bare parameter base names, in authored order.</param>
/// <param name="ParameterValues">The values bound under <paramref name="ParameterNames"/>, aligned index-for-index.</param>
/// <param name="MaxRows">The per-poll row ceiling; one row more refuses the whole query.</param>
public sealed record SqlNamedQuery(
string Name,
string Sql,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<object?> ParameterValues,
int MaxRows);