Closes the §7.2 priority-3 item. Sql was a shipped, merged driver with NO
user-facing documentation at all -- only design and plan files, which describe
what was intended rather than what an operator needs to author one.
Sql.md covers: SQL Server as the only constructed provider (the other four
SqlProvider names are reserved, and authoring one fails rather than falling
back), the KeyValue / WideRow tag models with worked TagConfig, Query being
rejected end-to-end, the config table with real defaults read from
SqlDriverOptions, connectionStringRef never carrying credentials into an
artifact, per-source query grouping and its case-sensitive group keys, and the
value-bound-vs-identifier-validated split that makes it injection-safe.
Calculation.md covers: what a pseudo-driver is and why it occupies a driver slot,
a Calculation-vs-VirtualTags decision table (the two are easy to confuse), the
literal-only ctx.GetTag dependency extraction and the runtime-built-path trap,
the two deploy gates, the all-deps-arrived publish gate, and the Good->Bad-once
error semantics.
Two stale claims found by checking against source rather than inheriting them:
- The driver README's Calculation row still carried G-1 ("no typed config form,
RunTimeout unauthorable"), fixed on 2026-07-27 by CalculationDriverForm.
- docs/Raw.md's "one auto-created default Engine device" NEVER SHIPPED. I had
copied it into the new doc before checking; "Engine" has zero occurrences in
src/ and nothing special-cases Calculation at device creation. Corrected in
both files. The config key was also wrong in my first draft: runTimeoutMs, an
int in milliseconds, not a TimeSpan string -- and it is ignored on reinit
because the evaluator's timeout is constructor-fixed.
Full solution: 49 suites pass; the 2 failures are the known pre-existing
environment ones (AbLegacy.IntegrationTests docker fixture, Host.IntegrationTests).
OpcUaClient.IntegrationTests, which was silently failing, is now green.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
7.3 KiB
SQL Poll Driver
Getting-started guide for the Sql driver — a read-only Equipment-kind driver that polls rows
out of a relational database and publishes them as OPC UA variables. For the design rationale read
docs/plans/2026-07-15-sql-poll-driver-design.md;
for the build-vs-plan record read
docs/plans/2026-07-24-sql-poll-driver.md.
What it talks to
An ordinary SQL database — typically a MES / LIMS / historian staging table that some other system
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
SELECTs on a timer and turns cells into tag values.
v1 constructs SQL Server only (Microsoft.Data.SqlClient). SqlProvider also declares
Postgres, MySql, Odbc and Oracle, but those are reserved names, not working backends —
authoring one fails the driver rather than silently falling back.
Read-only, structurally. SqlDriver does not implement IWritable, so no configuration can
produce a write. The allowWrites config field exists and is inert; the factory warns when it
is authored true rather than failing, on the grounds that the flag cannot do harm but an operator
who believes writes are enabled can.
Tag models
A tag says how to find its value. Two models ship; a third is deferred.
KeyValue — the EAV shape
One row per tag. The driver matches keyColumn = keyValue and reads valueColumn.
{
"model": "KeyValue",
"table": "dbo.TagValues",
"keyColumn": "TagName",
"keyValue": "Line1.Oven.Temp",
"valueColumn": "Value",
"timestampColumn": "UpdatedUtc"
}
WideRow — one row, many signals
The tag names its columnName; the row is picked either by a whereColumn/whereValue pair or by
newest-timestamp.
{
"model": "WideRow",
"table": "dbo.LineStatus",
"columnName": "OvenTemp",
"whereColumn": "LineId",
"whereValue": "L1"
}
{
"model": "WideRow",
"table": "dbo.Readings",
"columnName": "OvenTemp",
"topByTimestamp": "CapturedUtc"
}
Query — deferred to P3
Named arbitrary-SELECT. The v1 parser rejects it, and that refusal is enforced end-to-end —
parser, validator, planner and the AdminUI editor all decline it rather than accepting the value and
ignoring it later.
Optional per-tag fields
| Field | Effect |
|---|---|
timestampColumn |
Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
type |
Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. |
pollIntervalMs |
Per-tag override of the instance's defaultPollInterval. |
Driver config
{
"provider": "SqlServer",
"connectionStringRef": "MesStaging",
"defaultPollInterval": "00:00:05",
"operationTimeout": "00:00:15",
"commandTimeout": "00:00:10",
"maxConcurrentGroups": 4,
"nullIsBad": false
}
| Key | Default | Notes |
|---|---|---|
provider |
SqlServer |
The only provider v1 constructs. |
connectionStringRef |
— | A name, never a connection string. See below. |
defaultPollInterval |
00:00:05 |
Applied to any group without its own interval. |
operationTimeout |
00:00:15 |
Client-side wall-clock deadline; a breach surfaces BadTimeout. |
commandTimeout |
00:00:10 |
ADO.NET server-side backstop. Must be less than operationTimeout — it is the backstop, not the deadline. |
maxConcurrentGroups |
4 |
Connection-pool guard: cap on group queries in flight. |
nullIsBad |
false |
false ⇒ a NULL cell publishes Uncertain; true ⇒ Bad. |
allowWrites |
— | Inert. v1 has no write path; authoring true logs a warning. |
Credentials never enter the deployment artifact
connectionStringRef is a name resolved at Initialize from the environment / secret store — e.g.
Sql__ConnectionStrings__MesStaging. There is no field that holds a connection string, so a sealed
deployment artifact cannot carry database credentials even by accident.
A useful side effect of the in-place reinit added for #516: a rotated credential is picked up on the next config apply without a process restart, because the driver re-resolves the reference when it re-parses.
How polling works
Tags are grouped by source and one query is issued per group, not per tag — SqlGroupPlanner
emits a SqlQueryPlan per distinct group key. Grouping is deterministic (groups appear in the input
order of their first member, members keep their input order, parameters keep first-appearance order),
so an unchanged configuration produces byte-identical SQL.
⚠️ Group keys are case-sensitive on identifiers. Two tags authored dbo.T and DBO.T plan as
two groups — one extra round-trip, not a correctness problem, but worth knowing if a group count
looks higher than expected.
SQL injection: how the driver is safe
The two classes of authored string are handled differently, and deliberately so:
- Value-bearing fields (
keyValue,whereValue) are bound asDbParameters. They are never concatenated into command text. - Identifier-bearing fields (
table,keyColumn,valueColumn,timestampColumn,columnName,whereColumn,topByTimestamp) cannot be parameterised in SQL. They are validated againstINFORMATION_SCHEMAand dialect-quoted before they reach a command.
The parser deliberately does not sanitise either kind — sanitising a value would corrupt legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports properly.
⚠️ An unreadable catalog FAULTS the driver rather than rejecting every tag (Gitea #496). This is the safer failure: "I cannot verify these identifiers" and "these identifiers are invalid" are different conditions, and reporting the second when the first is true would send an operator hunting a configuration bug that does not exist. The catalog gate also substitutes the catalog's own spelling of an identifier, so a case difference between the authored blob and the database resolves rather than failing.
Browse
SqlDriverBrowser browses the live catalog — schema → table → column — so the /raw browse picker
can commit columns as tags. A committed leaf carries schema, table and column: a column name alone
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
typed editor opened empty.
Authoring in the AdminUI
- Driver config —
/raw→ driver node → Configure. Sql is a single-connection driver: the connection lives on the driver, not on a device, and the modal says so. - Tags — the typed
SqlTagConfigModeleditor, or CSV import (Sql has typed CSV columns), or browse-commit from the picker.
Capabilities
IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe.
No IWritable (structural, above). No IRediscoverable — a SQL schema change is not signalled, and
DiscoverAsync echoes authored config rather than enumerating the database, so this driver is
deliberately excluded from any discovery-diff machinery.
See also
docs/Raw.md— the/rawauthoring treedocs/drivers/README.md— the driver index- Gitea #496 / #497 / #498 — the shipped follow-ups