Files
lmxopcua/docs/drivers/Sql.md
T

8.5 KiB
Raw Blame History

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.

Three providers construct: SqlServer (Microsoft.Data.SqlClient), Postgres (Npgsql) and Oracle (ODP.NET managed — Oracle 12.2 or later, the floor the FETCH FIRST paging needs). SqlProvider also declares MySql and Odbc, but those remain reserved names, not working backends — authoring one fails the driver rather than silently falling back.

Two provider-specific things are worth knowing before you author against them:

  • Identifier case folds, and the catalog gate absorbs it. Postgres lower-cases and Oracle upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own spelling, so an authored widgets binds to Oracle's WIDGETS rather than failing.
  • Oracle NUMBER infers as Float64. Column metadata does not carry precision/scale to the type map, so an integer-shaped NUMBER still infers double. Use the per-tag type override (see Optional per-tag fields) when the OPC UA type matters.

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 SqlServer, Postgres or Oracle. MySql / Odbc are reserved and rejected.
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 as DbParameters. 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 against the live catalog — INFORMATION_SCHEMA on SQL Server and Postgres, the ALL_* views (ALL_USERS / ALL_TABLES ∪ ALL_VIEWS / ALL_TAB_COLUMNS) on Oracle — and 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.

Browse works against all three constructed providers; the catalog query is the dialect's own (INFORMATION_SCHEMA on SQL Server and Postgres — with pg_catalog / information_schema themselves excluded — and the ALL_* views on Oracle).

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 SqlTagConfigModel editor, 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