# 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
