# 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`](../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`](../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 `SELECT`s 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`. ```json { "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. ```json { "model": "WideRow", "table": "dbo.LineStatus", "columnName": "OvenTemp", "whereColumn": "LineId", "whereValue": "L1" } ``` ```json { "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 ```json { "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 as `DbParameter`s. 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 **`INFORMATION_SCHEMA`** 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. ## 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 - [`docs/Raw.md`](../Raw.md) — the `/raw` authoring tree - [`docs/drivers/README.md`](README.md) — the driver index - Gitea **#496** / **#497** / **#498** — the shipped follow-ups