merge: Sql family code review — default-schema fallback (whole-driver outage path), rowSelector doc fix, dead SqlClient ref; 18 findings recorded across 4 modules

This commit is contained in:
Joseph Doherty
2026-08-10 06:59:53 -04:00
8 changed files with 610 additions and 11 deletions
@@ -0,0 +1,80 @@
# Code Review — Driver.Sql.Browser
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
The base README.md is generated from these files by regen-readme.py — do not
edit README.md by hand. -->
| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser` |
| Reviewer | Claude Code |
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 3 |
## Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | 1 finding (001). The NodeId codec is sound — kind-prefixed arity, escaped separators, a dangling escape rejected rather than truncated — and the ordinal column match on attribute fetch is right for a case-sensitive collation |
| 2 | OtOpcUa conventions | No issues found — `DriverType` reads `DriverTypeNames.Sql`, the session is owned by the AdminUI `BrowseSessionRegistry` unchanged, and the project's no-runtime-driver layering (Gitea #526) holds and is guarded by `DriverBrowserLayeringTests` |
| 3 | Concurrency & thread safety | 1 finding (002). The single-permit gate correctly serialises catalog calls over one ADO.NET connection, and the disposed check inside the gate wait is the right place for it |
| 4 | Error handling & resilience | No issues found — a missing table yields an empty level rather than an error, a column terminal expand is a no-op, and the open path disposes the connection on every failure while the session owns it on success |
| 5 | Security | 1 finding (003). No name from a NodeId ever reaches a command text (schema/table are bound parameters, `QuoteIdentifier` is deliberately unused here), and the connection-string redaction on the open path is live-probed against the SqlClient parser's echo behaviour |
| 6 | Performance & resource management | No issues found. `AttributesAsync` re-reads the table's columns per call, which is one bounded catalog query per side-panel open and is what keeps the timestamp candidate consistent with the rendered tree |
| 7 | Design-document adherence | No issues found — the #552 decision (candidacy by declared TYPE, tie-break first-in-catalog-order, absent key rather than blank) is implemented exactly as `docs/drivers/Sql.md` and `SqlBrowseAddressFields` describe it |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | No issues found — 79 tests including the four `topByTimestamp` address-field cases (present, chosen-first, absent, and the browsed-column-is-the-timestamp case) and a concurrency-probing connection |
| 10 | Documentation & comments | No issues found |
## Findings
### Driver.Sql.Browser-001
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:290` |
| Status | Open |
**Description:** `PreferredTimestampColumn` nominates the first column whose dialect-mapped type is `DriverDataType.DateTime`. That set includes **date-only** types: `SqlServerDialect.MapColumnType` maps `"date"` to DateTime, and so does `PostgresDialect` for `date`. A table whose first date/time column is a `date` therefore commits `rowSelector.topByTimestamp = <date column>`, and the resulting wide-row query is `ORDER BY <date> DESC` limited to one row — which ties for every row written on the same day, so "the newest row" becomes storage order among the ties. The reader cannot warn about it either, because the server has already discarded the other rows (see Driver.Sql-008). The choice is a visible, editable default, so the impact is bounded to a tag that silently binds to an arbitrary row of the latest day until someone notices.
**Recommendation:** Prefer a time-bearing type when the table has one — i.e. rank candidates and fall back to a date-only column only when nothing better exists — or drop date-only types from candidacy entirely, which makes such a table "not bindable by browse" and routes it through the existing `DescribeUncommittableLeaf` message. Either way state the choice in `docs/drivers/Sql.md`, which currently describes candidacy as simply "date/time-typed".
**Resolution:** _(open)_
---
### Driver.Sql.Browser-002
| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:204`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:325` |
| Status | Open |
**Description:** Two small races around the registry reaper. (1) `DisposeAsync` disposes `_gate` unconditionally; a call already blocked in `_gate.WaitAsync` then throws `ObjectDisposedException` from inside the semaphore rather than the guarded `ObjectDisposedException.ThrowIf(_disposed, this)` the class raises everywhere else — the release side is defended against exactly this (`catch (ObjectDisposedException)` in the `finally`) but the wait side is not. (2) `LastUsedUtc` advances only after a call completes, so a slow catalog query (the AdminUI allows 20 s) is invisible to the TTL reaper for its whole duration; a session that idles up to the edge of the TTL and is then given one long expand can be disposed mid-call, tearing down the connection under an open reader.
**Recommendation:** Stamp `LastUsedUtc` on entry as well as on completion, and either leave `_gate` undisposed (the pattern `SqlPollReader` uses and documents) or drain it before disposing. Both are one-line changes; neither is observable to the picker today, which is why this is Low.
**Resolution:** _(open)_
---
### Driver.Sql.Browser-003
| Field | Value |
|---|---|
| Severity | Low |
| Category | Security |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs:262` |
| Status | Open |
**Description:** `OpenAsync` accepts an ad-hoc `connectionString` literal from the browse form and dials it verbatim. That is an unrestricted outbound-connect primitive on the AdminUI host: an arbitrary host and port, plus whatever the provider's keyword set allows (`Microsoft.Data.SqlClient` alone offers `AttachDbFilename`, `Failover Partner` and `Enlist`). The mitigations in place are real and deliberate — the literal is never persisted (`SqlDriverConfigDto` has no such property, and the picker composes a session-only document), a paste alongside a `connectionStringRef` is logged, `NodeEdit.razor` warns against pasting one into a saved DriverConfig, and every open-path failure is redacted — but the capability itself is bounded only by whichever AdminUI policy guards the browse endpoint.
**Recommendation:** No code change proposed — the surface is reached only from `/raw`, which carries `[Authorize(Policy = AdminUiPolicies.ConfigEditor)]` (Administrator or Designer), so it is already at the right authorization level. Record the trust boundary where a reviewer will see it (`docs/security.md`, alongside the other AdminUI-authenticated capabilities): a Designer can make the AdminUI host open an arbitrary outbound database connection, which is a stronger capability than "browse a configured database" reads as.
**Resolution:** _(open)_
@@ -0,0 +1,63 @@
# Code Review — Driver.Sql.Contracts
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
The base README.md is generated from these files by regen-readme.py — do not
edit README.md by hand. -->
| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts` |
| Reviewer | Claude Code |
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 2 |
## Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | 2 findings (001, 002) |
| 2 | OtOpcUa conventions | No issues found — provider-free by construction (the only `ProjectReference` is `Core.Abstractions`), enum-typed DTO fields with the converter applied by the factory rather than by an attribute, `*TagDefinitionFactory.Inspect` named for the reflection-discovered deploy-gate seam |
| 3 | Concurrency & thread safety | No issues found — every type here is a pure function or an immutable record |
| 4 | Error handling & resilience | No issues found — `TryParse` returns false rather than throwing for every refusal, and `Inspect` is total (the backstop warning covers a refusal none of its branches names) |
| 5 | Security | No issues found. `SqlTagDefinition`'s doc states the rule the rest of the driver enforces — value fields are bound, identifier fields are catalog-validated and dialect-quoted — and the parser deliberately does not sanitise either kind, which is correct: sanitising a value corrupts data and sanitising an identifier masks the authoring error the gate reports properly |
| 6 | Performance & resource management | No issues found — one `JsonDocument` per blob, disposed |
| 7 | Design-document adherence | No issues found. The accept-gate-first `Inspect` (run the runtime parser, return empty on success, only then explain) is the #549–#559 arc's rule implemented structurally rather than promised, and the strict enum reads are the deliberate R2-11 behaviour |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | No issues found — the parser, the strict-enum rejections and the injection-shaped `keyValue` are covered from `Driver.Sql.Tests`, and `Inspect` is covered by the AdminUI parity corpus |
| 10 | Documentation & comments | No issues found |
## Findings
### Driver.Sql.Contracts-001
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:65`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs:132` |
| Status | Open |
**Description:** The two value-bearing fields are read asymmetrically and nothing documents it. `whereValue` goes through `ReadScalar`, which accepts a JSON string, number or boolean and captures the raw token — the design doc's own wide-row example authors it as the number `7`. `keyValue` goes through `ReadString`, so a JSON number is read as null and the tag is refused outright. Both end up bound as a `DbParameter` and both are matched back as text (`IndexByKey` stringifies the key cell), so there is no downstream reason for the difference. The refusal is also mis-explained: `Inspect`'s branch is `ReadString(root, "keyValue") is null`, so a numerically-authored key draws "Sql KeyValue tag has no 'keyValue'" — telling the operator the field is missing when it is right there in the blob.
**Recommendation:** Read `keyValue` with the same `ReadScalar` the row selector uses (the value is bound either way), and mirror the change in `Inspect` so the accept gate and its explanation stay the same parse. If the string-only rule is intended, say so in the rejection text and in `docs/drivers/Sql.md` instead — the current wording is the failure mode the #549 arc calls out, an authoring surface disagreeing with the runtime about *why*.
**Resolution:** _(open)_
---
### Driver.Sql.Contracts-002
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs:50` |
| Status | Open |
**Description:** Cross-module, and reachable through every driver rather than only this one. The strict enum read is `TagConfigJson.TryReadEnumStrict`, which classifies via `Enum.TryParse(text, ignoreCase: true, …)` — and that method accepts a **numeric string**, returning `true` for a value that is not a declared member. So `"type": "99"` reads as `JsonEnumRead.Valid`, the parser accepts the tag, and `SqlTagDefinition.DeclaredType` carries `(DriverDataType)99`: `DiscoverAsync` materialises a variable whose declared type is undefined, and `SqlPollReader.TryCoerce` falls through its switch to the `String` arm, so the node reads a stringified cell under a data type nothing can map. `"model": "7"` is harmless here only by luck — the planner's `default` arm rejects it. Nothing in the module can fix this alone, because the accept/reject authority is deliberately the shared helper.
**Recommendation:** Add an `Enum.IsDefined` check to `TagConfigJson.ClassifyEnumText` so a numeric spelling is classified `Invalid` (and therefore refused by the strict read and named by `DescribeInvalidEnum`) — one change covering all six parsers and both the runtime and authoring doors. Cross-module, so recorded rather than fixed here; verify the parity corpus in `AdminUI.Tests` after the change, since it exercises every driver's `Inspect`.
**Resolution:** _(open)_
@@ -0,0 +1,80 @@
# Code Review — Driver.Sql.Dialect
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
The base README.md is generated from these files by regen-readme.py — do not
edit README.md by hand. -->
| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` |
| Reviewer | Claude Code |
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 3 |
## Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | 1 finding (001). The three implementations agree member-for-member on the contract, and the two places a dialect must diverge — ODP.NET's marker/bind-name split and `BindByName` — are modelled explicitly rather than derived |
| 2 | OtOpcUa conventions | No issues found. The assembly is `.Dialect` while the namespace stays `ZB.MOM.WW.OtOpcUa.Driver.Sql`, which is the documented `Driver.Mqtt.Transport` precedent — the split is a build-graph concern, and the csproj says so |
| 3 | Concurrency & thread safety | No issues found — every dialect is stateless and immutable, which is what lets one instance be shared by the poll reader and a browse session |
| 4 | Error handling & resilience | No issues found. `MapColumnType` never throws and falls back to `String`; `ConfigureCommand` tolerates a foreign `DbCommand` rather than casting blind |
| 5 | Security | No issues found. `QuoteIdentifier` escapes the one metacharacter that can terminate a quoted identifier on each backend, rejects control and Unicode-format characters (the Trojan-Source class), and never echoes the offending value; the catalog SQL binds every runtime value as a parameter |
| 6 | Performance & resource management | No issues found — the catalog statements are bounded by their `WHERE` clauses and hold no state |
| 7 | Design-document adherence | No issues found — the constructed set (SqlServer, Postgres, Oracle) matches `docs/drivers/Sql.md` and the reserved `MySql`/`Odbc` names fail loudly at every construction site |
| 8 | Code organization & conventions | 1 finding (002) — a member the consumer needs and the seam does not carry |
| 9 | Testing coverage | No issues found for the offline surface (`SqlServerDialectTests`, `PostgresDialectTests`, `OracleDialectTests` pin quoting, markers, row-limit ends and the type maps). The catalog SQL itself is only ever parsed by a real server, which is what the Docker `*.IntegrationTests` exist for — the `p_` bind prefix defect that shipped was invisible offline for exactly this reason |
| 10 | Documentation & comments | 1 finding (003) |
## Findings
### Driver.Sql.Dialect-001
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs:178`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:212` |
| Status | Open |
**Description:** Both identifier ceilings are enforced as `ident.Length`, i.e. UTF-16 code units, but the server limits they stand in for are **byte** limits: PostgreSQL's `NAMEDATALEN - 1` is 63 bytes and Oracle 12.2's is 128 bytes, both measured in the database's encoding (UTF-8 in any modern estate). A non-ASCII identifier therefore passes this check and is still truncated (Postgres) or rejected (Oracle) server-side — 40 Cyrillic characters are 80 UTF-8 bytes. Postgres' truncation is the failure this check documents itself as existing to prevent: "the server silently truncates anything longer, so a 70-character authored name would resolve to a different object than it spells". The residual risk is small because the catalog gate substitutes catalog spellings, and a name that came back from the catalog is by construction within the server's own limit — so this bites only the "future caller that has no catalog to check against" the same doc contemplates.
**Recommendation:** Measure the limit the way the server does — `Encoding.UTF8.GetByteCount(ident)` — for the two byte-limited dialects, and note in the remarks that T-SQL's `sysname` is genuinely a 128-*character* limit so `SqlServerDialect` is correct as written.
**Resolution:** _(open)_
---
### Driver.Sql.Dialect-002
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs:29` |
| Status | Open |
**Description:** The seam models identifier quoting, catalog SQL, row-limit syntax, parameter spelling and command setup — everything that differs per backend *except* the per-command bind-parameter ceiling, which also differs per backend (2100 for `Microsoft.Data.SqlClient`, 1000 entries in an Oracle `IN` expression list, 65535 for Npgsql). Its absence is why `SqlGroupPlanner` cannot chunk a key-value `IN` list and a large EAV group fails permanently at poll time (Driver.Sql-002). The seam's own summary is the argument for putting it here: a call site that guesses a provider limit it does not own is the exact defect the interface exists to prevent.
**Recommendation:** Add a `MaxBindParameters` (or `MaxInListEntries`) member with each dialect stating its own value, and have the planner emit several plans per group key above it. Recorded rather than fixed because the consumer-side change is a planner restructure, not a seam edit — see Driver.Sql-002.
**Resolution:** _(open)_
---
### Driver.Sql.Dialect-003
| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs:135` |
| Status | Open |
**Description:** `OracleDialect.ListSchemasSql` reads `ALL_USERS`, and its remarks justify the divergence from the other two dialects (which list only schemas that own a visible relation) purely from the catalog gate's point of view: "harmless here — the list is used to resolve an authored schema name … a schema holding no tables simply resolves and then matches no relation". That is true of the gate and false of the other consumer: `SqlBrowseSession.RootAsync` renders the same query as the browse tree's root level, so on an Oracle estate the operator is offered every database *user* as an expandable schema — hundreds of them on a real installation, nearly all empty, with no way to tell which hold anything. The two consumers of one query have different needs and only one is considered.
**Recommendation:** Either restrict the query to owners of a visible relation (`SELECT DISTINCT owner FROM ALL_TABLES UNION SELECT DISTINCT owner FROM ALL_VIEWS`), matching what the other two dialects list and what the browse tree wants, or state the browse consequence in the remarks so it is a known trade rather than an oversight. Note the change would also narrow the gate, which is a behaviour change for a tag naming an empty schema — currently it resolves and then fails at the table level; afterwards it would fail at the schema level with a different message.
**Resolution:** _(open)_
+210
View File
@@ -0,0 +1,210 @@
# Code Review — Driver.Sql
<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
The base README.md is generated from these files by regen-readme.py — do not
edit README.md by hand. -->
| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql` |
| Reviewer | Claude Code |
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 7 |
## Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | 3 findings (001 fixed, 005, 006) |
| 2 | OtOpcUa conventions | No issues found — read-only capability set, `DriverTypeNames.Sql` chained off the shared constant, logging through `ILogger` only, xUnit + Shouldly suites, no authorization decisions in driver code |
| 3 | Concurrency & thread safety | 1 finding (010). The atomic snapshot swap of the authored/polled tables, the `_gate`-bounded group concurrency and the slot-owned-by-the-work-task discipline are sound |
| 4 | Error handling & resilience | 2 findings (002, 004) |
| 5 | Security | 1 finding (009). The injection boundary itself is sound — see the note under Findings |
| 6 | Performance & resource management | No issues found — one connection per group bounded by `maxConcurrentGroups`, rows materialised before the reader is disposed, abandoned work detached and self-disposing |
| 7 | Design-document adherence | 1 finding (003) — the shipped getting-started guide disagreed with the parser |
| 8 | Code organization & conventions | 1 finding (007) |
| 9 | Testing coverage | No issues found. 365 tests across parser/planner/reader/gate/probe/factory over a real-SQLite poll fixture, with a colon-marker dialect as the provider-assumption control and an injection regression suite; the Postgres/Oracle/SQL Server/blackhole paths are covered by the Docker `*.IntegrationTests` |
| 10 | Documentation & comments | 2 findings (003 fixed, 008) |
## Findings
Security note (category 5), recorded here rather than as a finding because it is the module working as
designed: every authored *value* (`keyValue`, `whereValue`) reaches the database as a `DbParameter`, and
every authored *identifier* is resolved against the live catalog by `SqlCatalogGate` and **replaced with the
catalog's own spelling** before `ISqlDialect.QuoteIdentifier` sees it, so the identifier text in an emitted
statement is a string the server itself returned. `SqlPollReader` resolves only through `_polledByRawPath`,
which is populated exclusively by the gate and cleared by `BuildTagTable`, so there is no path from an
unvalidated identifier to a command text. The resolved connection string is passed to the provider and to
nothing else — every log line, health message and host status carries `Endpoint`'s credential-free
`server/database` rendering, and exception *messages* are reduced to the exception type on every operator
surface.
### Driver.Sql-001
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs:139` |
| Status | Resolved |
**Description:** `ReadDefaultSchemaAsync`'s own remarks documented a fallback — "falling back to the single visible schema keeps the common one-schema database working" — that the method did not implement: a null/blank answer returned `string.Empty`. The default schema is then the empty string, `SqlCatalog.TryResolveSchema(null)` hands `""` to `TryResolveTable`, the loader issues the tables query for schema `''`, it matches nothing, and every unqualified authored table on the driver is rejected by the catalog gate and reads `BadNodeIdUnknown` — a whole-driver outage against a healthy database with every tag correctly authored. The blank answer is reachable in production: a SQL Server login with no `DEFAULT_SCHEMA` mapped, and a Postgres connection whose `search_path` resolves to nothing, both answer null. No test covered it, because the test dialect's `DefaultSchemaSql` is the literal `SELECT 'main'`.
**Recommendation:** Implement the documented fallback — when the dialect's answer is null/blank use the sole visible schema and only that one; with several visible schemas there is no defensible guess and the empty string must continue to resolve nothing.
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). `LoadAsync` now passes the already-loaded schema list into `ReadDefaultSchemaAsync`, which falls back to `schemas[0]` when exactly one schema is visible. TDD: `SqlCatalogDefaultSchemaTests` was written red (the unqualified tag read `BadNodeIdUnknown`) and carries a falsifiability control proving the ambiguous multi-schema case still rejects rather than guessing.
---
### Driver.Sql-002
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs:127` |
| Status | Open |
**Description:** `BuildKeyValuePlan` emits one bind parameter per distinct `keyValue` in a group, with no upper bound and no chunking. Every provider caps this: `Microsoft.Data.SqlClient` allows 2100 parameters per command, and Oracle limits an `IN` expression list to 1000 entries. A key-value (EAV) staging table is precisely the shape that attracts thousands of tags on one table — which is one group by the group-key rule — so above the cap the provider rejects the statement after the connection opened, `QueryAsync` returns `GroupResult.Failed`, and every tag in that group publishes `BadCommunicationError` on every poll, permanently. The failure is also mis-diagnosed: `ObservePollOutcome` classifies `BadCommunicationError` as connection-class, so the driver reports Degraded and the host `Stopped` (see Driver.Sql-005), pointing the operator at the network rather than at a tag count. Nothing upstream caps the tag count per driver or per table, and the log line names the SQL but not the cause.
**Recommendation:** Chunk the `IN` list. The natural shape is a `MaxBindParameters` member on `ISqlDialect` (see Driver.Sql.Dialect-002) and a planner that emits several plans for one group key when the member count exceeds it — `SqlPollReader.Slice` already maps per `plan.Members`, so several plans sharing a group key need no reader change. Failing that, gate the tag count at deploy time with a message naming the provider limit, rather than letting it surface as a permanent per-poll communication error.
**Resolution:** _(open)_
---
### Driver.Sql-003
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Design-document adherence |
| Location | `docs/drivers/Sql.md:55`, `docs/drivers/Sql.md:90` |
| Status | Resolved |
**Description:** Two claims in the shipped getting-started guide disagreed with the code. (1) Both `WideRow` examples spelled the row selector at the top level (`"whereColumn": …`, `"topByTimestamp": …`), but `SqlEquipmentTagParser.TryParse` reads those keys only from a nested `rowSelector` object — a blob authored from the doc carries no row selector, so the parser refuses the tag, the driver skips it, and it reads `BadNodeIdUnknown` forever. This is the same defect class as Gitea #552, arriving through the documentation rather than through the browse-commit mapper. (2) The "Optional per-tag fields" table documented a `pollIntervalMs` per-tag override; no such field exists anywhere in the tree — `SqlTagDefinition` has no member for it and nothing reads the key, so an operator authoring it silently gets the instance default.
**Recommendation:** Correct both: nest the selector keys inside `rowSelector` in both examples with a warning that the top-level spelling is refused, and delete the `pollIntervalMs` row, stating where the interval actually comes from.
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). Both examples now use the nested `rowSelector` form and carry an explicit warning about the top-level spelling; the `pollIntervalMs` row is replaced by a paragraph naming the subscriber interval / `defaultPollInterval` / `PollGroupEngine.DefaultMinInterval` chain and recording that the field never existed. The scalar/string asymmetry between `whereValue` and `keyValue` (Driver.Sql.Contracts-001) is documented in the same edit.
---
### Driver.Sql-004
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:651` |
| Status | Open |
**Description:** `HandlePollError` skips the connection class by testing `ex is DbException`, on the stated premise that "a subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY classified". Not every unreachable-database throw is a `DbException`: `SqlPollReader.QueryAsync` raises `InvalidOperationException` when the provider factory returns no connection, and a provider may raise `ArgumentException`/`FormatException` from a malformed connection string on the same open path. For those, `ReadAsync` writes the good endpoint-bearing `LastError` and transitions the host to `Stopped`, rethrows, and the poll engine then routes the same exception here — where `Degrade` overwrites that message with the guidance-free "poll failed: TypeName" and the outage is logged a second time. The comment describes the intended behaviour; the type test does not implement it.
**Recommendation:** Mark the exception as already-classified at the one place that knows (e.g. `ReadAsync` wrapping the throw, or a per-call flag) rather than inferring it from the exception type, so the reader's richer `LastError` survives.
**Resolution:** _(open)_
---
### Driver.Sql-005
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:641` |
| Status | Open |
**Description:** `IsConnectionClass` treats `BadCommunicationError` as "the database did not answer", so a poll carrying it reports the endpoint `Stopped`. But that code is minted by `GroupResult.Failed`, which the reader raises only after the connection has opened — its own comment says "the connection opened, so the server is up — this query is what failed (an identifier that is not in the catalog, a lock, a permission)". A single group failing on a lock, a revoked `SELECT`, a schema change or an over-long `IN` list (see Driver.Sql-002) therefore reports the whole database endpoint as down. The two types disagree about what the same status code means.
**Recommendation:** Either restrict the connection class to `BadTimeout` (a wedged socket is the case the host-state signal is for), or give a post-open query failure its own status so the two classifications cannot drift. Note the host state is currently only consumed through `IHostConnectivityProbe`, which is unwired (Gitea #521) — the operator-visible half today is the `LastError` wording and the Degraded verdict.
**Resolution:** _(open)_
---
### Driver.Sql-006
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:599` |
| Status | Open |
**Description:** `ObservePollOutcome` advances `LastSuccessfulRead` only when at least one snapshot is Good. Several codes are proof the database answered without being Good: `BadNoData` (the query ran, the row is absent), `Uncertain`/`Bad` (a present row with a NULL cell) and `BadTypeMismatch` (a cell came back and would not coerce). A driver whose every tag reads one of those — a truncated staging table, a key set that was deleted — leaves `LastSuccessfulRead` frozen. That is benign on its own (health is correctly left unchanged, because these are authoring/data facts), but it interacts with the comms-loss watchdog: once anything has degraded the driver, the conjunction "non-Healthy AND `LastSuccessfulRead` frozen for 90 s" (CLAUDE.md, Gitea #527) trips against a database that is answering every poll on time, and the node force-reconnects a healthy connection.
**Recommendation:** Advance `LastSuccessfulRead` on evidence-of-answer codes, not only on Good — the honest reading of "the database answered" the watchdog was designed around — while leaving the health state classification exactly as it is.
**Resolution:** _(open)_
---
### Driver.Sql-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj:21` |
| Status | Resolved |
**Description:** The project carried a direct `Microsoft.Data.SqlClient` `PackageReference` that no source file in it uses — every type speaks `System.Data.Common` only, and the concrete provider packages moved behind the `.Dialect` leaf when `ISqlDialect`/`SqlServerDialect` were extracted (Gitea #526). Beyond the dead dependency, it reads as "this project is SQL-Server-specific", which is the assumption the dialect seam exists to prevent.
**Recommendation:** Drop the reference (the `.Dialect` project reference already supplies it transitively for anything that needs it) and record why the project deliberately carries no provider package.
**Resolution:** Fixed on this review branch (`review/sql` `69ad376f`, 2026-08-10). Reference removed and replaced with a comment stating the rule; the module's 365 tests still pass.
---
### Driver.Sql-008
| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs:556` |
| Status | Open |
**Description:** `SelectWideRow`'s remarks justify giving the `topByTimestamp` form the dialect's single-row limit on the grounds that "there the `ORDER BY` makes 'the first row' a defined answer". That holds only when the ordering column is unique. `ORDER BY ts DESC` plus a one-row limit over a column with ties returns an arbitrary row among the tied ones, and — unlike the where-pair form, whose whole argument for not taking the limit is that `Rows.Count > 1` is the evidence of ambiguity — the reader can never detect it, because the server has already discarded the other rows. The browse-commit default makes the tie shape reachable rather than theoretical: `SqlBrowseSession.PreferredTimestampColumn` will happily nominate a `date`-typed column (see Driver.Sql.Browser-001), which ties for every row written on the same day.
**Recommendation:** State the tie caveat where the claim is made, and in `docs/drivers/Sql.md` alongside the `topByTimestamp` example: the model requires a selector that identifies one row, and a non-unique ordering column silently does not.
**Resolution:** _(open)_
---
### Driver.Sql-009
| Field | Value |
|---|---|
| Severity | Low |
| Category | Security |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs:83` |
| Status | Open |
**Description:** The probe surfaces the raw parser text on a malformed config ("Config JSON is invalid: " + `ex.Message`) where `SqlDriverBrowser.ParseDocument` deliberately withholds the identical message "because the malformed text may carry a connection string". The two read the same blob shape: the AdminUI Sql picker composes an ad-hoc document carrying a top-level `connectionString` literal, so a malformed paste is exactly the case that reaches both. `System.Text.Json`'s message quotes the offending token and the JSON path rather than the property value, so the current exposure is small — but the rule is stated absolutely elsewhere in this driver ("never `ex.Message` on an operator surface") and the probe is the one place that breaks it.
**Recommendation:** Match the browser: report the failure class, not the parser's message.
**Resolution:** _(open)_
---
### Driver.Sql-010
| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:675` |
| Status | Open |
**Description:** Every health transition is a read-modify-write over a `Volatile`-accessed field with no compare-and-swap: `Degrade`, `TouchLastSuccessfulRead` and `SetHealthUnlessFaulted` each `ReadHealth()`, build a new record and `WriteHealth()` it. Concurrent poll loops (one per subscription, plus an ad-hoc `ReadAsync`) can interleave between the read and the write, so a `LastSuccessfulRead` advance can be lost, or a `Faulted` verdict recorded by a concurrent reinitialize can be overwritten by a stale non-Faulted snapshot despite the guard — the guard reads the field before the write rather than atomically with it. Impact is limited to the health surface (a stale timestamp or state until the next poll), which is why this is Low.
**Recommendation:** Use `Interlocked.CompareExchange` in a small retry loop for the read-modify-write helpers, or funnel health mutation through a lock — the `_probeLock` the host-state transition already uses would do.
**Resolution:** _(open)_
+16 -4
View File
@@ -56,13 +56,18 @@ One row per tag. The driver matches `keyColumn = keyValue` and reads `valueColum
The tag names its `columnName`; the row is picked either by a `whereColumn`/`whereValue` pair or by
newest-timestamp.
⚠️ **The selector keys live inside a nested `rowSelector` object**, not at the top level. A blob that
spells `whereColumn` / `whereValue` / `topByTimestamp` beside `columnName` carries *no* row selector
as far as `SqlEquipmentTagParser` is concerned: the tag is refused, the driver skips it, and it reads
`BadNodeIdUnknown` forever. The typed editor, the browse-commit mapper and the deploy gate all use
the nested form.
```json
{
"model": "WideRow",
"table": "dbo.LineStatus",
"columnName": "OvenTemp",
"whereColumn": "LineId",
"whereValue": "L1"
"rowSelector": { "whereColumn": "LineId", "whereValue": "L1" }
}
```
@@ -71,10 +76,13 @@ newest-timestamp.
"model": "WideRow",
"table": "dbo.Readings",
"columnName": "OvenTemp",
"topByTimestamp": "CapturedUtc"
"rowSelector": { "topByTimestamp": "CapturedUtc" }
}
```
`whereValue` may be authored as a JSON string, number or boolean (it is bound as a parameter either
way). `keyValue` on the `KeyValue` model must be a **string**.
### `Query` — deferred to P3
Named arbitrary-`SELECT`. **The v1 parser rejects it**, and that refusal is enforced end-to-end —
@@ -87,7 +95,11 @@ ignoring it later.
|---|---|
| `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`. |
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
subscriber names none. (A `pollIntervalMs` per-tag override was documented here for a while and never
existed in the parser — nothing reads it.)
## Driver config
@@ -74,7 +74,7 @@ internal static class SqlCatalogLoader
"the catalog views, which is a grant problem rather than a tag-authoring one");
var defaultSchema = await ReadDefaultSchemaAsync(
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
connection, dialect, schemas, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
@@ -132,12 +132,23 @@ internal static class SqlCatalogLoader
/// query answers null/blank.
/// </summary>
/// <remarks>
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
/// visible schema keeps the common one-schema database working; with several visible schemas there is
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
/// <para>A null answer is possible (a login with no default schema mapped, or a Postgres connection
/// whose <c>search_path</c> resolves to nothing). Falling back to the single visible schema keeps the
/// common one-schema database working; with several visible schemas there is no defensible guess, so
/// unqualified names simply will not resolve and the gate says so by name.</para>
/// <para><b>The fallback is load-bearing, not cosmetic.</b> Without it a blank answer resolves the
/// default schema to the empty string, the tables query runs for schema <c>''</c>, it matches nothing,
/// and <em>every unqualified tag on the driver</em> is rejected by the gate and reads
/// <c>BadNodeIdUnknown</c> — a whole-driver outage against a healthy database with every tag correctly
/// authored. Guessing among several schemas is the opposite error (a tag silently bound to the wrong
/// relation), which is why the fallback stops at exactly one candidate.</para>
/// </remarks>
private static async Task<string> ReadDefaultSchemaAsync(
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
DbConnection connection,
ISqlDialect dialect,
IReadOnlyList<string> schemas,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
@@ -147,7 +158,11 @@ internal static class SqlCatalogLoader
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var schema = value is null or DBNull ? null : value.ToString();
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
if (!string.IsNullOrWhiteSpace(schema)) return schema;
// No default schema for this login: the sole visible schema is the only defensible answer, and
// with more than one there is none — the empty string then resolves nothing, by design.
return schemas.Count == 1 ? schemas[0] : string.Empty;
}
}
@@ -18,7 +18,11 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<!-- No provider package here on purpose: every type in this project speaks System.Data.Common
only (DbProviderFactory / DbConnection / DbCommand), and the concrete provider packages live
behind the .Dialect leaf referenced above. A direct Microsoft.Data.SqlClient reference sat
here unused after that extraction and read as "this project is SQL-Server-specific", which
is exactly the assumption the dialect seam exists to prevent. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,135 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The catalog loader's <b>default-schema fallback</b>: a login with no default schema mapped (the
/// dialect's <c>DefaultSchemaSql</c> answers NULL/blank — a real Postgres shape when the connection's
/// <c>search_path</c> resolves to nothing, and a real SQL Server shape for a login with no
/// <c>DEFAULT_SCHEMA</c>) must still resolve an <em>unqualified</em> authored table when the connection
/// can see exactly one schema.
/// <para>Without the fallback the loader resolves the default schema to the empty string, the tables
/// query is issued for schema <c>''</c>, it matches nothing, and <b>every unqualified tag on the driver
/// is rejected by the gate</b> and reads <c>BadNodeIdUnknown</c> — a whole-driver outage with the
/// database perfectly healthy and every tag correctly authored.</para>
/// </summary>
public sealed class SqlCatalogDefaultSchemaTests
{
private const string DriverInstanceId = "sql-default-schema";
private const string ConfigJson = """{"provider":"SqlServer"}""";
/// <summary>
/// One visible schema + a blank default ⇒ the unqualified name resolves in that schema and polls.
/// </summary>
[Fact]
public async Task An_unqualified_table_resolves_when_the_server_reports_no_default_schema()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect());
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(
"a login with no default schema must still resolve an unqualified name in the sole visible schema");
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// The falsifiability control: with <b>more than one</b> visible schema there is no defensible guess,
/// so an unqualified name must NOT be resolved against an arbitrary one — it is rejected, and the gate
/// says so by name. A fallback that picked the first schema would pass the test above and silently
/// bind tags to whichever schema sorted first here.
/// </summary>
[Fact]
public async Task An_unqualified_table_is_still_rejected_when_several_schemas_are_visible()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect(twoSchemas: true));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
private static SqlDriver NewDriver(SqlitePollFixture fixture, ISqlDialect dialect)
=> new(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
dialect,
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
private static RawTagEntry KvEntry(string rawPath, string keyValue)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{SqlitePollFixture.KeyValueTable}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{SqlitePollFixture.ValueColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="DefaultSchemaSql"/> (which
/// answers the empty string, standing in for a login with no default schema) and optionally
/// <see cref="ListSchemasSql"/> (which can report a second, empty schema so the ambiguous case is
/// reachable).
/// </summary>
/// <remarks>
/// A decorator rather than a subclass, for the reason <c>SqlCatalogGateDriverTests</c> gives:
/// <see cref="SqliteDialect"/> is sealed, and <c>new</c>-hiding a property would leave interface
/// dispatch calling the base, so the substitution would silently not happen.
/// </remarks>
private sealed class BlankDefaultSchemaDialect(bool twoSchemas = false) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public SqlProvider Provider => Inner.Provider;
public DbProviderFactory Factory => Inner.Factory;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string ListSchemasSql => twoSchemas
? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA"
: Inner.ListSchemasSql;
/// <summary>The server has no default schema for this login — the case the fallback covers.</summary>
public string DefaultSchemaSql => "SELECT ''";
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
public void ConfigureCommand(DbCommand command) => Inner.ConfigureCommand(command);
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
}