Compare commits

...

42 Commits

Author SHA1 Message Date
Joseph Doherty 47d148daf9 fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.

New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
  display never implies text that isn't there (the old markup appended "…"
  unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.

Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:

  Scripts.razor                    SourceHash    (the reported crash)
  Certificates.razor               Thumbprint    (read off the on-disk store)
  Deployments.razor  x2            RevisionHash
  Clusters/ClusterOverview.razor   RevisionHash

The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.

NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.

Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.

Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
2026-07-26 09:43:48 -04:00
Joseph Doherty 755ae1aa3f merge: re-assert non-normal scripted-alarm conditions after a (re)load (#487)
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 3h14m40s
Closes the redeploy-reset gap on Part 9 scripted-alarm condition nodes — the
alarm analogue of the VirtualTag ReassertValue fix.

A full address-space rebuild re-materialises every condition node fresh, while
the engine's reload produces EmissionKind.None for an alarm whose state did not
change — so nothing wrote the node. Live-reproduced on docker-dev: a cleared-
but-still-UNACKNOWLEDGED alarm came back reporting acknowledged, dropping Retain
to false, so it vanished from ConditionRefresh entirely. The operator's
outstanding alarm silently disappeared from the alarm list on deploy.

ScriptedAlarmHostActor.ReassertConditionNodes now writes one AlarmStateUpdate
per alarm not in the Part 9 no-event position, sourced from the new
ScriptedAlarmEngine.GetProjections(). Node-only by construction — the projection
type carries no EmissionKind, so it cannot become an alerts row or a duplicate
historian record.

Live gate PASSED (pre-fix image vs. rebuilt, central-2): condition absent
pre-fix, present + Unacknowledged + Retain=True post-fix, stamped with its own
LastTransitionUtc; /alerts empty across two re-asserts with the panel proven
live by a real ACTIVATED transition.
2026-07-26 09:18:23 -04:00
Joseph Doherty 549c656489 fix(alarms): re-assert non-normal scripted-alarm conditions after a (re)load (#487)
A deploy that triggers a FULL address-space rebuild clears `_alarmConditions`
and re-materialises every condition node fresh — inactive, acked, confirmed,
unshelved. The engine reloads its persisted state and re-derives Active from the
predicate, so it ends up correct; but there is no transition to report, so
`LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it. Nothing
writes the node.

Live-reproduced on the docker-dev rig against the pre-fix image: an alarm that
had fired and cleared but was still UNACKNOWLEDGED came back from a rebuild
reporting Acknowledged with Retain=false — so it disappeared from
ConditionRefresh entirely, while the engine still held Unacknowledged. An
operator's outstanding alarm silently vanishes from the alarm list on deploy.

`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load it
reads the new `ScriptedAlarmEngine.GetProjections()` and sends one
`AlarmStateUpdate` per alarm NOT in the Part 9 no-event position.

Load-bearing properties:

- Node-only. It sends `AlarmStateUpdate` and nothing else — never the `alerts`
  topic, never the telemetry hub. `alerts` is the historization path, so a row
  per alarm per deploy would append a duplicate historian/AVEVA record every
  time anyone deploys. This is why it reads a projection rather than re-emitting:
  `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for
  the emission machinery — or a future refactor — to mistake for a transition.
  The issue's sketch said `GetState(alarmId)` + `LoadedAlarmIds`; that alone
  would have clobbered the node's severity and message, so the projection also
  carries the definition's severity, the resolved message template, and the
  last-observed worst-input quality.
- Only non-normal alarms. One in the no-event position already matches what the
  materialise built, so a never-fired alarm stays byte-identical to a deploy
  without this pass (including its Message, which the materialise seeds with the
  display name), and an all-normal deploy writes nothing at all.
- Timestamp is the condition's own `LastTransitionUtc`, not the deploy instant.
- No duplicate Part 9 events: `WriteAlarmCondition`'s delta-gate compares against
  the node's CURRENT state, so a re-assert onto a freshly materialised node is a
  genuine delta (fires once — correct, the rebuild reset what clients see) while
  one onto a surgically-preserved node is suppressed.

Tests: 6 new host tests + 6 new engine tests. Falsifiability checked by
disabling the call — 4 of the 6 host tests go red; the other 2 are absence
guards against an over-broad fix and pass either way by design.

Live gate (docker-dev, central-2, pre-fix image vs. rebuilt image):
- pre-fix: condition absent from ConditionRefresh after a rebuild;
- post-fix: present, Unacknowledged, Retain=True, stamped with its own
  LastTransitionUtc rather than the restart instant;
- /alerts stayed empty across two re-asserts, with the panel proven live by a
  real ACTIVATED transition immediately afterwards — no duplicate history.
2026-07-26 09:06:39 -04:00
Joseph Doherty 28c2866710 merge: Sql driver follow-ups #496/#497/#498 + the Runtime.Tests flake #500 (fix/sql-driver-followups)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 15m14s
Four commits closing the three follow-ups the Sql poll driver left behind, plus the
intermittent Runtime.Tests failure found while verifying them.

- #497 corrects 16 wrong OPC UA status-code constants across 6 drivers (the issue named
  3) and adds StatusCodeParityTests, a reflection guard checking every driver's
  hard-coded uint against the pinned SDK. That guard is what found the other 13.
- #498 fails the deploy when a Sql driver's persisted config carries a literal
  connectionString — the write-side half of a guarantee only enforced on read.
- #496 implements the design 8.1 catalog gate: authored table/column names are resolved
  against the live catalog at Initialize and replaced with the catalog's own spelling, so
  quoting becomes the backstop it was documented to be rather than the sole defence.
- #500 fixes the Runtime.Tests flake: four ordering/logic defects plus a class of tight
  presence budgets, now routed through a documented RuntimeActorTestBase.PresenceBudget.

Verified: full solution builds, all 41 unit-test projects green, Sql integration suite
21/21 against the real SQL Server on 10.100.0.35, and 30 consecutive Runtime.Tests runs
clean against a measured 13% baseline.

Still open by design: #499 (ClusterNode.DriverConfigOverridesJson is a third config
persistence surface DraftValidator cannot see) and #501 (two alarm-ack tests use
AwaitAssert for an absence assertion, so they prove nothing; the rewrite may
legitimately turn them red).
2026-07-25 22:27:26 -04:00
Joseph Doherty 154171f48c test(runtime): fix the intermittent Runtime.Tests failure — 4 ordering/logic defects + the presence-budget class (#500)
The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test
name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it
was never ONE flaky test: five failures across three distinct tests in that batch, and
five more distinct tests over the verification rounds that followed.

Two hypotheses were tested and DISPROVED by measurement before anything was changed:

- Cluster-formation timeout. Every test in this assembly forms a real single-node Akka
  cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up
  — 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero
  timeouts. A 3.4x margin; not the cause.
- Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout.
  Not the cause.

FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout):

1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place
   The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and
   then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register,
   Unregister] interleaving LastSender is the DYING child and the assertion fails. Now
   captures the sender of the Register message as it arrives. Fully deterministic; no
   timing involved. (Swept the other four LastSender sites: all drain the Unregister
   first, so their ordering is fixed. Only this one was unsafe.)

2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath
   ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is
   still in Connecting, which deliberately fast-fails writes ("driver not connected"),
   and the NodeId->driver reverse map has not been pushed. Now retries until accepted.
   This does not weaken the assertion — every rejection branch replies WITHOUT reaching
   the driver, so Writes.Count.ShouldBe(1) still means exactly what it did.

3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_...
   One 500 ms constant served both presence budgets (AwaitAssert) and absence windows
   (ExpectNoMsg). Different quantities that happened to share a number, so the presence
   budget could not be raised without slowing every absence check. Split into
   AssertTimeout (5 s) and Settle (500 ms).

4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks
   Waited on `outbox == 0`, which is ALSO true before the first append — so the poll
   could win the race against the recorder and return having observed the INITIAL state,
   after which the CallCount>=2 check outside the block failed against a recorder that
   had not run. Both conditions are now polled together with the retry count as the
   discriminator. (The preceding test in the same file documents this exact trap.)

THE PRESENCE-BUDGET CLASS:

After those four, three consecutive 30-run rounds each still failed once — on a
DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s,
OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a
distribution, not a defect, and fixing it one test at a time was the wrong method.

RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s
AwaitAssert/AwaitCondition durations across four files route through it.

Why this is safe rather than sloppy: a presence budget is an upper bound before giving
up, not a wait — these helpers poll and return the instant the condition holds. Raising
one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass;
it only changes how quickly a real breakage reports. Absence windows are the opposite
(the elapsed time IS the assertion) and were deliberately left untouched with their own
short, individually-calibrated literals.

ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its
8 s budget was sized for message passing but actually waits on a REAL Roslyn compile
(ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold
script compiles are the slowest and most variable thing in the assembly, so that class
gets 30 s with the reason recorded.

Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13%
baseline. Full solution builds; all 41 unit-test projects green.

NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use
AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They
are excluded from the budget change on purpose — a bigger number does not fix them, and
rewriting them to settle-then-assert may legitimately turn them red.
2026-07-25 20:20:06 -04:00
Joseph Doherty 4dc2ad8084 feat(sql): implement the design §8.1 catalog identifier-validation gate (#496)
Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored
table/column names: an identifier went from the TagConfig blob straight into a
command text, bracket-quoted. The residual risk was bounded — a hostile name is
quoted into one nonexistent object, the query fails after the connection opens,
and the tag Bad-codes — but "bounded" is not "filtered", and the design promised
a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so.

SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against
the live catalog at Initialize, and REPLACE it with the catalog's own spelling.
The identifier text in an emitted poll query is therefore a string this driver
read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting
becomes the backstop it was documented to be.

Decisions worth knowing before touching this:

- Substitution, not just validation. Matching is exact-ordinal first, then a
  UNIQUE case-insensitive hit: SQL Server's default collation is CI so case
  variants have always worked, and rejecting them would break valid deployments.
  An ambiguous CI match under a case-sensitive collation is refused rather than
  guessed — picking one would publish another column's data under the operator's
  node, which is worse than rejecting the tag.

- Charset check BEFORE catalog lookup. Each identifier goes through
  QuoteIdentifier for its rejection rules first, so a name carrying a control or
  Unicode format character is rejected WITHOUT its value being echoed into a log
  line (Trojan-Source). A name that passes is safe to render, which is why
  catalog-miss messages do name it — an operator hunting a typo has to see what
  they wrote. Both halves are pinned by tests.

- A rejected tag keeps its node. It is dropped from the POLLED table but stays in
  the AUTHORED table, so it still materializes and reads BadNodeIdUnknown —
  §8.1's specified outcome. My first wiring dropped it from both, which deleted
  the node instead; an existing test (ReinitializeAsync_recoversFromFaulted)
  caught it. A status code can only be published by a node that exists, and a
  missing address-space entry is far harder to diagnose than a Bad quality.

- An unreadable catalog FAULTS Initialize; it does not reject every tag. That is
  the absence of evidence about the tags, not evidence against them — rejecting
  all of them would serve a confidently-empty address space and send the operator
  hunting typos that do not exist. Zero visible schemas is treated the same way,
  because that is exactly what a missing GRANT looks like. Faulting lands
  DriverInstanceActor in Reconnecting with its retry timer running.

- Bounded load: one schema list, one default-schema scalar, then one ListTables
  per distinct authored schema and one ListColumns per distinct authored relation
  — never a full catalog enumeration, and nothing after Initialize. Every
  authored name reaches the catalog queries as a bound @schema/@table parameter,
  so building the allow-list cannot itself be an injection vector.

- ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified
  `TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo
  would be a silent lie on any estate that maps service accounts to their own
  default schema. It is a query, not a constant, because the answer is
  per-connection.

- Accepted v1 limitation: a 3-part db.schema.table (or linked-server name)
  addresses a catalog this connection cannot enumerate, so it cannot be
  allow-listed and is rejected with a message pointing at the fix — expose the
  data through a view in the connected database.

VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and
reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a
second hand-rolled copy. (It also fixes a latent bug in the extracted code: the
OperationCanceledException arm detached the wrong task.)

Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite
catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that
suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA
path). Verified falsifiable: bypassing the gate turns exactly the three rejection
assertions red and leaves the rest green.

SqlInjectionRegressionTests is deliberately NOT rewritten to expect
BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the
quoting backstop on its own — defence in depth is only worth the name if each
layer holds independently. Rewriting those assertions would delete the backstop's
only coverage and leave the gate a single point of failure. Its scope note, which
said the gate does not exist, is updated to say why it stays where it is.
2026-07-25 16:07:53 -04:00
Joseph Doherty 1919a8e538 feat(config): reject a persisted Sql connectionString at the deploy gate (#498)
The Sql driver's "a pasted literal connection string is never persisted" guarantee was
enforced only on the READ path: SqlDriverConfigDto has no connectionString property and
UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being
WRITTEN. Config blobs are schemaless JSON columns and the fallback authoring pattern for
a driver without a typed form is a raw-JSON textarea, so an operator pasting
{"connectionString":"Server=...;Password=..."} would put a live credential in the
ConfigDb — persisted, replicated to every node's artifact cache, readable by anyone with
config access — while the runtime silently ignored it and the driver failed to connect.
Discarding a secret on read is not the same as refusing to store it.

DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy
(SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the
DeviceConfig of any device beneath it, carries a top-level connectionString key.

- Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the
  DTO sees it — a credential pasted into the device blob is the identical leak.
- The key is matched case-insensitively: System.Text.Json binds ConnectionString to a
  connectionString property by default, so an ordinal match would leave the obvious
  bypass open.
- Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and
  is not the mistake this rule exists to catch.
- The message never echoes the value — validation errors reach the AdminUI, the deploy
  log and the audit trail, and repeating the string there would leak the credential the
  rule is refusing to store. Pinned by a test.
- Malformed/blank/non-object JSON never throws: a parse failure would take down every
  other rule in the same pass.

16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail
and the 10 negatives stay green, so none of them passes vacuously.

Design §8.2's "if an inline connectionString is ever permitted (dev convenience only),
the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a
credential that has already been written.

Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third
persistence surface for a driver config blob, but it is not part of DraftSnapshot, so
this validator cannot see it.
2026-07-25 15:37:48 -04:00
Joseph Doherty b95b99ba8e fix(drivers): correct 16 wrong OPC UA status-code constants + guard them by reflection (#497)
Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by
reflection, not by copying siblings. All are client-visible: OPC UA clients branch on
status.

The three named in #497:
  - Historian.Gateway SampleMapper "BadNoData"  0x800E0000 -> 0x809B0000
    (0x800E0000 is BadServerHalted)
  - Galaxy "BadTimeout"                         0x800B0000 -> 0x800A0000
    (0x800B0000 is BadServiceUnsupported)
  - TwinCAT BadTypeMismatch                     0x80730000 -> 0x80740000
    (0x80730000 is BadWriteNotSupported)

BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS,
AbLegacy and AbCip carried the same 0x80730000. Every call site is a
FormatException/InvalidCastException conversion failure, so the name was right and
the value was wrong in all four.

Adding the reflection guard then surfaced nine further defects offline tests had
never checked:
  - Galaxy GoodLocalOverride  0x00D80000 -> 0x00960000  (not a UA code at all)
  - Galaxy UncertainLastUsableValue        0x40A40000 -> 0x40900000
  - Galaxy UncertainSensorNotAccurate      0x408D0000 -> 0x40930000
  - Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000
  - Galaxy UncertainSubNormal              0x408F0000 -> 0x40950000
  - TwinCAT BadOutOfService   0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported)
  - TwinCAT BadInvalidState   0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid)
  - AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent)
  - Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000

Galaxy's whole Uncertain block read as though the OPC DA quality byte could be
shifted into the UA substatus position; it cannot — the substatuses are an unrelated
enumeration. GatewayQualityMapper already held the correct table, which is what the
Galaxy values are now reconciled against.

Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project-
references every driver) reflects over every `const uint` in the deployed
ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it
equals Opc.Ua.StatusCodes.<name>. Discovery is by convention, so a new driver
assembly referenced by that project is covered with no edit here. It went red on all
nine defects above before the fixes and now checks 109 constants green.

Because reflection can only see a NAMED constant, two inline literals were hoisted so
the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and
GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline
`0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived.

The drivers stay SDK-free by design; only the test project references Opc.Ua.Core,
as the oracle.

Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests,
SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source),
a mislabelled comment in DriverInstanceActorTests, and stale constants in two design
docs, one of them the unexecuted MTConnect driver design that would have propagated
BadNoData's wrong value into a new driver.

The CLI's SnapshotFormatter value->name table was checked against the SDK and is
correct; no change needed.
2026-07-25 15:34:31 -04:00
Joseph Doherty 4ad540376d merge: SQL poll driver (feat/sql-poll-driver)
v2-ci / build (push) Successful in 4m13s
v2-ci / unit-tests (push) Failing after 3h14m20s
Read-only Sql Equipment-kind driver: polls SQL Server tables/views on an
interval and publishes selected columns/rows as OPC UA variable nodes. Three
projects mirror the Modbus split (Contracts / runtime / Browser); ISqlDialect
seam (SqlServer only in v1); grouped one-query-per-source reads with a
three-layer client-side deadline; schema-walk address picker; typed AdminUI
driver-config + tag-config editors; env-gated central-SQL integration + a
dedicated-container blackhole gate.

Live /run gate PASSED end-to-end on docker-dev: authored a Sql driver +
device + KeyValue tag through the AdminUI, deployed, and read the live value
(dbo.TagValues.Line1.Speed = 42.5, Good) back over OPC UA.

Follow-ups filed: Gitea #496 (§8.1 catalog gate), #497 (cross-driver status
codes), #498 (connectionString persist guard).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:01:26 -04:00
Joseph Doherty 57a5bb3c60 chore(docker-dev): wire Sql__ConnectionStrings__DevSql for the Sql driver live gate
Adds the named connection-string ref the Sql poll driver resolves
(connectionStringRef: "DevSql") to both central nodes' env, pointing at a
DevSql database on the rig's own sql container. Committed-dev-secret exception,
same posture as the ConfigDb line above it. This is the env wiring the Task 21
live /run gate used to author + deploy a Sql driver and read a live value
(dbo.TagValues.Line1.Speed = 42.5) back over OPC UA.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:59:27 -04:00
Joseph Doherty b295b24419 feat(adminui): author + configure the Sql driver in /raw
The read-only Sql driver's runtime factory/probe and tag-side editors were
wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type
list, the DriverConfigModal switch, and the legacy identity dropdown all omitted
Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this.

- Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider
  (SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a
  connection string — label is explicit), optional poll/operation/command
  timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via
  JsonStringEnumConverter; a connection-string literal can never be authored
  (no such field is collected); rawTags (composer-owned) + allowWrites (inert,
  read-only v1) are never emitted.
- Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and
  DriverIdentitySection legacy dropdown.
- Add SqlDriverFormContractTests: the exact JSON the form emits constructs a
  driver through the real factory, missing connectionStringRef is rejected, and
  provider round-trips as the "SqlServer" name (never an ordinal).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:41:33 -04:00
Joseph Doherty 585f827ef3 fix(sql): close the residual credential leak in HandlePollError
The C1 credential-hygiene fix converted InitializeAsync and ReadAsync to
surface only ex.GetType().Name, but left HandlePollError building its
operator-facing Degrade() message from raw ex.Message, gated only by
'if (ex is DbException) return;'. That guard exists for the I1
double-classification concern, not for message safety, and it is incomplete
for the leak vector: a malformed connection string throws ArgumentException
from the keyword parser (the same unquoted-';'-in-password shape the sibling
SqlDriverBrowser.Sanitize special-cases), which is not a DbException and so
reaches Degrade(ex.Message) unredacted.

It is unreachable in today's control flow only because the connection string
is static and validated identically at Initialize first — an emergent
property, not an enforced invariant, and a live per-poll leak the moment a
future edit (refresh-on-reinit, a different provider) breaks that assumption.

Make the fallback type-only like the other two sites; the full exception still
reaches the log sink via the exception parameter. The I1 defer + control tests
are unaffected (they assert Degraded, not message text). Pinned by a new test
driving a credential-bearing ArgumentException through HandlePollError;
verified load-bearing (red against Degrade(ex.Message), green after).

Closes the C1 review finding on commit 37f444b9.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:39:14 -04:00
Joseph Doherty be1df2d1e5 feat(sql): SqlTagConfigEditor razor shell
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:35:25 -04:00
Joseph Doherty 9b5a96876e feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql
Add DriverTypeNames.Sql (+ All) as the shared source of truth, and wire the driver
into the Host: register the factory in DriverFactoryBootstrap.Register via
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory), add the
SqlProbe (= Driver.Sql.SqlDriverProbe) probe via TryAddEnumerable, and add the
Driver.Sql project reference to the Host. Default tier A (SQL client is managed +
cross-platform, so ShouldStub needs no change).

Repoint the interim SqlDriver.DriverTypeName / SqlDriverFactoryExtensions.DriverTypeName
literals at DriverTypeNames.Sql; both still resolve to "Sql", so the AdminUI
TagConfigEditorMap/TagConfigValidator keys and the DriverTypeName-parity test stay
green. The Core guard test discovers factories from its own bin, so it also gains a
Driver.Sql project reference — that is the "registered-factory side" that lets
DriverTypeNamesGuardTests' bidirectional-parity check pass (4/4 green).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:35:09 -04:00
Joseph Doherty 898c7365c4 test(sql): close the blackhole gate's leaked-pause race + bound the shell command
Review follow-up on the blackhole gate. Two robustness gaps, both bounded to the
disposable dedicated container (never shared infra), fixed:

- If the outer token fired while 'docker pause' was in flight, 'paused' was set
  only AFTER the await, so a cancellation there left paused=false while the
  container could still complete the pause — the finally then skipped unpause and
  leaked a frozen container. Mark paused BEFORE the await, and make the finally's
  unpause best-effort (unpausing a never-actually-paused container errors
  harmlessly, and a cleanup failure must never mask the real test outcome).
- The pause/unpause shell commands had no wall-clock bound of their own — only the
  post-pause read was capped — so a hung SSH handshake would hang CI. Give
  RunShellCommandAsync its own 30s hard cap that kills the process and throws
  TimeoutException, distinct from an operator's own cancellation.

Offline skip still clean (1 skipped, 9ms, no socket/docker).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:29:21 -04:00
Joseph Doherty 37f444b99c fix(sql): stop leaking provider exception text; de-dup health classification; guard Faulted
C1: SqlDriver's Initialize/Read failure paths no longer interpolate the ADO.NET
provider's ex.Message into LastError, the thrown message, or host status — only
ex.GetType().Name reaches the operator surface; the full exception goes to the log
sink via the structured logger's exception parameter. The driver is dialect-agnostic
over an arbitrary DbProviderFactory, so it cannot rely on any provider's message
discipline (Microsoft.Data.SqlClient's parser echoes an unrecognised keyword
lower-cased, which a value-based redactor misses).

C1a: replace the vacuous credential test (SQLite's "unable to open" message never
contains the connection string, so it passed regardless) with one that fabricates a
DbException whose own .Message carries a credential token and drives it through the
Initialize liveness-failure path via the injectable factory seam — red against the
pre-fix ex.Message interpolation.

I1: HandlePollError now ignores the DbException class ReadAsync already classified,
so a subscribed-read outage is classified (and logged) once, not twice with the
worse message.

I2: the poll's Healthy verdict routes through a shared SetHealthUnlessFaulted guard
(the one Degrade already used), so a late in-flight poll cannot un-fault a driver a
concurrent ReinitializeAsync just faulted.

I3: DiscoverAsync warns when materializing an omitted-type tag as String, the only
operator signal for the declared-vs-published type mismatch on a numeric column.

M1: BuildTagTable wraps the per-entry parse so a stray non-JsonException throw skips
the tag instead of stranding health at Initializing (it runs before Initialize's
try/catch). M2 left as a documented TODO to avoid touching SqlPollReader.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:28:17 -04:00
Joseph Doherty 443b718158 feat(sql): typed AdminUI Sql tag-config model + validator (string enums)
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's
accept/reject boundary byte-for-byte on the fields they own: model/type
serialize as NAME strings (never numbers), KeyValue requires
table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a
selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys
(top-level and nested rowSelector) survive load->save. Registered in
TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName
(DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder
SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI.

The load-bearing test rounds editor output back through
SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:27:14 -04:00
Joseph Doherty 02f2dafe2a test(sql): correct the cancelled-token test's overclaim
Its doc said it pinned "the real provider's cancellation path", but the
token is cancelled before ReadAsync so the OCE is raised at the reader's
SemaphoreSlim.WaitAsync gate, before any SqlConnection opens — it never
exercises Microsoft.Data.SqlClient's in-flight command cancellation.
Softened (option (a)) to state accurately what it proves: an already-
cancelled token propagates as OCE and is never swallowed into a Bad
snapshot or an unreachable-database verdict. The in-flight/deadline path
is covered by SqlBlackholeTimeoutTests.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:20:59 -04:00
Joseph Doherty 10a6a37ca5 test(sql): blackhole/timeout live-gate on a dedicated mssql container
Adds the frozen-peer / BadTimeout gate for the Sql driver — the single
highest-value integration test. Mirrors the S7 R2-01 blackhole gate:
docker pause a DEDICATED mssql mid-poll, assert the next read surfaces
BadTimeout within the client-side operationTimeout (≈3s) and well below
the server-side CommandTimeout backstop (30s), the driver degrades
(Degraded + host Stopped), and docker unpause recovers to Healthy/Running.

- Docker/docker-compose.yml: disposable `otopcua-sql-blackhole` mssql on
  :14333 (never the shared :14330 ConfigDb server) + one-shot seed.
- Docker/seed.sql: the two sample tables.
- SqlBlackholeTimeoutTests.cs: env-gated (SQL_BLACKHOLE_ENDPOINT); pauses
  ONLY the hard-coded container name; skips loudly if the endpoint is the
  shared port 14330; bounds its own wait so a wedged impl fails, not hangs.
  Offline: clean skip (no socket, no docker shell-out).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:20:24 -04:00
Joseph Doherty a55b7e51ca test(sql): injection regression — bind values, reject unknown identifiers
Locks the driver's injection guarantee against the SQLite fixture. An
authored VALUE ('; DROP TABLE TagValues; --) binds as a DbParameter and can
only ever be a key that matches no row (BadNoData); the seed table survives.
A hostile IDENTIFIER in table/column position is dialect-quoted into a
single nonexistent identifier — inert — and the payload never executes.

Scope, stated plainly: design §8.1 also specifies a catalog gate (validate
an authored identifier against INFORMATION_SCHEMA, reject an unknown one as
BadNodeIdUnknown). That gate does NOT exist in the driver yet and no task
here builds it, so a hostile identifier is not rejected up front — it is
quoted, the query fails after the connection opened, and the tag Bad-codes
as a query failure (BadCommunicationError), not BadNodeIdUnknown. These
tests assert what the code actually guarantees — the payload is inert and
the table intact — rather than a catalog gate that isn't there. The gate is
a tracked follow-up.

No implementation change: the reader already binds correctly (Task 7); this
suite pins it. Falsifiability-checked — forcing a real DROP before the
row-count assertion turns it red, so "table survives" is load-bearing.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:13:32 -04:00
Joseph Doherty 14fe89c505 feat(sql): AdminUI browser DI + Sql address picker body
Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy
lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor:
a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a
column attribute side-panel, manual-entry model/selector fields retained, that
composes the per-tag TagConfig blob (KeyValue / WideRow) matched to
SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and
never persisted into the composed blob or driver config.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:11:56 -04:00
Joseph Doherty 4c716242ac feat(sql): SqlDriverProbe SELECT-1 liveness check
The AdminUI "Test Connect" probe for the Sql driver: open a connection, run
the dialect's LivenessSql (SELECT 1) under a linked-CTS deadline, return
green with latency or red with a reason. Implements IDriverProbe; never
throws (malformed JSON, missing connectionStringRef, unprovisioned
connection string, a provider open failure, timeout, cancellation all
become red results).

Parses the SAME factory DTO with the SAME JsonStringEnumConverter as
SqlDriverFactoryExtensions (R2-11 factory parity, mirroring
ModbusDriverProbe) and resolves connectionStringRef the same way, so a
config that Test-Connects is the config that Deploys.

Credential hygiene: the resolved connection string never reaches the result
message. A provider exception can embed the data source in its OWN message
(the real SqlException shape), so the catch-all names the exception TYPE
only, never ex.Message. The regression test's fake connection embeds the
connection string in its thrown message specifically so surfacing ex.Message
would leak it — verified load-bearing by breaking the guard and watching it
go red.

ForTest injects factory+dialect for the offline SQLite fixture path.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:11:32 -04:00
Joseph Doherty 3cc8069150 feat(sql): factory + connectionStringRef env resolution + string-enum guard
SqlDriverFactoryExtensions turns a deployed DriverConfig blob into a live
SqlDriver, and SqlConnectionStringResolver resolves the authored
connectionStringRef NAME from Sql__ConnectionStrings__<ref> so credentials
never ride in a config blob (design §8.2).

Three behaviours worth naming:

- String-enum guard. JsonOptions carries JsonStringEnumConverter +
  camelCase, so `provider` parses whether authored as a name or an ordinal
  and always round-trips as the NAME — the systemic AdminUI defect where a
  page serialised an enum numerically against a string-typed DTO.
- Config validation lands here because nothing below does it.
  operationTimeout must be STRICTLY greater than commandTimeout (design
  §8.3); the reader and the driver stay deliberately usable with the pair
  inverted so the frozen-database tests can prove the client-side bound
  fires. Non-positive timeouts / poll interval and maxConcurrentGroups < 1
  are rejected too.
- Credential hygiene. The resolved connection string reaches the provider
  and nothing else: messages name the ref, the environment variable, or
  SqlDriver.Endpoint's credential-free server/database rendering. Both a
  log-leak and an exception-leak test cover it.

DTO changes:
- Adds RawTags (List<RawTagEntry>), following the Modbus precedent — the
  deploy artifact delivers a driver's tags this way and the DTO had no way
  to receive them, so an authored Sql tag could never reach the driver.
- Deletes SqlProbeDto + the `probe` key. It had no consumer: SqlDriver was
  specified without a background probe loop, and deliberately so — its
  IHostConnectivityProbe state is a by-product of the Initialize liveness
  check and of every poll, i.e. a statement about traffic that actually
  happened rather than a synthetic ping. On-demand connectivity is Task
  10's SqlDriverProbe. Shipping a config key that silently does nothing is
  worse than not shipping it; UnmappedMemberHandling.Skip means a blob that
  still carries `probe` keeps parsing.

allowWrites stays inert (SqlDriver implements no write capability) but an
authored `true` now WARNS rather than passing silently — the flag cannot do
harm, an operator who believes writes are enabled can.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:06:58 -04:00
Joseph Doherty 48cbc26c34 test(sql): env-gated central-SQL integration fixture + read round-trip
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:06:55 -04:00
Joseph Doherty 9e7fa5d11f feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal
Opens one transient DbConnection from a form-supplied driver-config blob and
hands ownership to SqlBrowseSession, which closes it on disposal. A database is
named either by connectionStringRef — resolved in the AdminUI process from
Sql__ConnectionStrings__<ref>, with an unresolvable ref naming the exact missing
variable — or by a pasted, session-only literal. The literal wins and the ref is
then not read; it cannot arrive from a persisted blob (SqlDriverConfigDto has no
such property), so its presence proves an operator typed it now.

Credential hygiene is the point of the type: no cached config field, nothing
persisted, and no connection text in any log line or exception. Live-probed:
Microsoft.Data.SqlClient and Microsoft.Data.Sqlite keep connection-string values
out of SqlException/SqliteException, but their connection-string PARSER echoes an
unrecognised keyword verbatim — and an unquoted ';' inside a password splits, so
the tail of the password is reported as a keyword (Password=Sup3r;SecretTail =>
"Keyword not supported: 'secrettail;connect timeout'."). The parser's message is
therefore never surfaced; every other failure additionally passes through a
substring redactor that drops the inner exception when it fires.

DriverType comes from SqlDriver.DriverTypeName, the driver's interim local
constant — DriverTypeNames.Sql is added by the driver-factory task.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:02:17 -04:00
Joseph Doherty e3e3f5fb04 fix(sql): report an ambiguous wide-row selector; pin the zombie-slot bound
Three review findings against SqlPollReader (967e5140).

1. WideRow's where-pair form emits no ORDER BY and no row limit, so a
   selector that is not actually unique made the reader publish an
   arbitrary, storage-order-dependent row — silently, unlike IndexByKey's
   duplicate-key warning. Row selection moves out of MapMember into a new
   SelectWideRow, which is called once per group and emits the same
   rate-limited contract warning (naming table, selector and row count).

   The row-limit half of the suggested fix was deliberately NOT taken: a
   TOP 1 with no ORDER BY is not deterministic either, and it would
   destroy the Rows.Count > 1 evidence the warning depends on, turning a
   loud misconfiguration into a permanently silent one. Rationale is in
   SelectWideRow's docs. No SQL changed, so no planner golden string moved.

2. The "a frozen database can never accumulate more than
   maxConcurrentGroups connections" claim was asserted in the docs and
   untested. ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQuery-
   TrulyFinishes wedges a group behind an EXCLUSIVE transaction, times it
   out, and proves the slot is still held (a second poll on a cap of 1
   times out on the gate without ever reaching the factory) and is handed
   back only once the abandoned query truly finishes. Moving the release
   from the work to the waiter fails this test and no other.

3. Task.Run thread-pool starvation: documented, not changed.
   Microsoft.Data.SqlClient's async path parks no pool thread on a frozen
   server, so the driver cannot starve itself; LongRunning would cost a
   dedicated OS thread per group per poll forever to insure against a
   provider this driver does not ship. RunGroupAsync now records the
   decision and what a BadTimeout does and does not mean, for the
   blackhole gate.

Also: the timeout warning now names the group's table, so a BadTimeout
storm identifies which source is frozen.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:57:14 -04:00
Joseph Doherty 861a1d1df0 feat(sql): SqlBrowseSession schema-walk over dialect catalog
Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with
@schema/@table bound as parameters at every level. Column leaves carry the
dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only
v1).

The NodeId encoding deliberately departs from the design sketch's literal
`schema.table|column`: SQL Server permits `.` and `|` inside a quoted
identifier, so that form mis-parses (main.a.b|c reads equally as schema
`main`+table `a.b` and schema `main.a`+table `b`) and silently binds an
operator's tag to the wrong column. SqlBrowseNodeId encodes
`<kind>:<part>[|<part>...]` with `\`/`|` escaped and the kind prefix carrying
the arity; it is public because the picker body decodes it back.

The session owns the connection it is handed and closes it on dispose -- the
registry-held session is the only lifetime hook, so a non-owning session would
leak one pooled connection per reaped picker. Per-call work stays bounded by
the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout);
no second deadline is invented here.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:49:36 -04:00
Joseph Doherty 9b30bdeb7a feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery
SqlDriver implements IDriver / ITagDiscovery / IReadable / ISubscribable /
IHostConnectivityProbe / IAsyncDisposable over the landed SqlPollReader,
mirroring ModbusDriver. IWritable is deliberately absent: v1 is read-only
structurally, and every discovered variable is SecurityClass=ViewOnly.

The shell classifies poll outcomes because nothing below it does. The reader
honours IReadable literally — it throws only when the database is unreachable
and Bad-codes everything else — so a frozen database returns all-BadTimeout
snapshots through a perfectly successful PollGroupEngine tick. Without
ObservePollOutcome the driver would report Healthy while every value was Bad.
Connection-class codes (BadTimeout / BadCommunicationError) degrade health and
report the host Stopped; authoring-class codes (unresolvable RawPath, absent
row, type mismatch) change nothing, so a tag typo never reports the database
down. It deliberately does NOT synthesise an exception to earn engine backoff:
the engine's exception path publishes nothing, which would cost clients the
Bad quality the reader went out of its way to produce.

Initialize builds the authored RawPath table first (pure, cannot fail; a
malformed TagConfig is logged and skipped) then verifies liveness over one
open-use-dispose connection bounded by wall clock as well as by token (the
R2-01 lesson) — failure records Faulted and rethrows so DriverInstanceActor
retries. The connection string is never logged: a credential-free
server/database Endpoint is the only rendering that reaches a log, LastError,
or the host status.

DriverTypeNames.Sql is NOT added here — DriverTypeNamesGuardTests asserts
bidirectional parity with registered factories, so the constant must land with
the factory (Task 11). SqlDriver.DriverTypeName carries the string until then.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:48:14 -04:00
Joseph Doherty 0efee7413a fix(sql): SqlQueryPlan snapshots its collections instead of aliasing the planner's lists
Code-review Minor: the record typed ParameterNames/Parameters/Members/
SelectedColumns as IReadOnlyList<T> but was handed the planner's live List<T>
instances. SqlQueryPlan documents itself as safe to hold for plan caching, so
an aliased list is a real hazard once the reader starts reusing plans across
polls: IReadOnlyList<T> is downcastable, and one mutation would corrupt every
subsequent poll served by that plan.

Copying at the record boundary fixes it for every call site at once rather
than relying on each planner branch remembering to call AsReadOnly.

Pinned by a test that mutates the caller's lists after construction; verified
load-bearing (reverting the record turns it red).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:31:35 -04:00
Joseph Doherty 967e5140f1 feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back
Executes a poll pass: resolve refs -> SqlGroupPlanner.Plan -> one command per
group -> slice the result set back to per-tag DataValueSnapshots, positionally
aligned with the caller's reference list (design §3.2).

The frozen-peer contract (design §8.3, the R2-01 S7 lesson) is the core of it.
CommandTimeout is only the server-side backstop; the client-side bound is a
linked CancelAfter PLUS Task.WaitAsync, because a linked token bounds only a
provider that honours it and ADO.NET providers vary. The whole per-group
operation — waiting for a concurrency slot, OpenAsync, and the query — runs off
one operationTimeout budget, so ReadAsync returns on time regardless of what the
database is doing. A breach Bad-codes that group (BadTimeout); caller
cancellation propagates instead, since nobody consumes a torn-down poll.

Also: absent row (BadNoData) stays distinct from a NULL cell (Uncertain, or Bad
under nullIsBad); duplicate plan members are all fed (two tags on one keyValue
bind one parameter but stay two members); the concurrency slot is released by the
work, not the waiter, so a frozen database can never hold more than
maxConcurrentGroups connections; the whole call throws only when the connection
itself cannot open, which is what earns PollGroupEngine's backoff.

SqlStatusCodes is driver-local, matching every sibling driver's own table — there
is no shared helper in Core.Abstractions and drivers do not reference the OPC UA
SDK. Values were read off Opc.Ua.StatusCodes rather than copied from a sibling,
because two of those tables carry transcription errors.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:26:29 -04:00
Joseph Doherty 50f88b938f fix(sql): reject Unicode format chars in identifiers; stop overclaiming catalog validation
Three findings from the code review of the dialect seam:

- QuoteIdentifier used char.IsControl, which only covers Unicode category Cc.
  Bidi overrides, zero-width spaces, soft hyphen and BOM are category Cf and
  passed through untouched. They cannot escape the brackets, so this is not an
  injection bypass — but the method rejects control characters precisely to
  protect the integrity of logged/audited statements, and Cf characters spoof
  rendered text while comparing byte-different from the real catalog name
  (Trojan Source, CVE-2021-42574). Same rule, same rationale, wider category.

- The XML docs on both files asserted that identifiers 'are sourced only from
  catalog-validated (INFORMATION_SCHEMA) names' and that rejection is 'a
  backstop, not the primary defence'. Neither is true yet: design §8.1 does
  specify that gate, but nothing implements it and no task in the plan
  schedules one, so an authored TagConfig table/column reaches QuoteIdentifier
  unfiltered. The docs now say so, because under-scrutinising this method on
  the strength of a filter that does not exist is exactly the failure mode.

- The control-character test only pinned NUL, so a regression to a
  Contains('\0') check would still have passed. Pinned the whole Cc category
  plus the new Cf rule; verified load-bearing by deleting the guard and
  observing exactly the 5 new cases go red.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:07:54 -04:00
Joseph Doherty a05cff794c test(sql): SqliteDialect + poll fixture
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:04:06 -04:00
Joseph Doherty 6de782e0fd test(sql): cover the WideRow + rejection branches of the equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:59:01 -04:00
Joseph Doherty 3d7e1226d2 refactor(sql): promote the single-row limit onto ISqlDialect
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:57:25 -04:00
Joseph Doherty 984cc875e8 feat(sql): SqlGroupPlanner folds tags into one parameterized query per group
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:52:27 -04:00
Joseph Doherty 6bb0108d30 feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL
The dialect owns the two things ADO.NET's System.Data.Common base types
cannot abstract: identifier quoting and the metadata-catalog SQL. It is
the driver's SQL-injection boundary — values always bind as DbParameters,
identifiers cannot, so the few that reach a command text are catalog-
sourced and pass through QuoteIdentifier.

QuoteIdentifier brackets one identifier part and doubles embedded ] (the
only metacharacter that can terminate a bracketed identifier early);
rejects null/empty/whitespace, >128 chars (T-SQL sysname ceiling), and any
control character (incl. NUL) as ArgumentException, without echoing the
untrusted value into the message. MapColumnType folds the design §3.7
families case-insensitively and never throws — an unrecognised family
falls back to String so a browse over an exotic column still renders;
timestamp/rowversion and time are deliberately NOT mapped to DateTime.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:43:53 -04:00
Joseph Doherty 531a110ffc chore(sql): align Driver.Sql csproj with sibling driver LangVersion convention
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:37:52 -04:00
Joseph Doherty 53ff041229 feat(sql): SqlTagDefinition + strict-enum equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:36:51 -04:00
Joseph Doherty 5b7fe9336e feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:34:50 -04:00
Joseph Doherty 3b75e1ac69 feat(sql): scaffold Driver.Sql.Browser project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:31:09 -04:00
Joseph Doherty 227cf8ee2b feat(sql): scaffold Driver.Sql runtime project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:30:11 -04:00
Joseph Doherty b13ff0be46 feat(sql): scaffold Driver.Sql.Contracts project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:29:25 -04:00
111 changed files with 15207 additions and 149 deletions
+4
View File
@@ -77,6 +77,10 @@
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
hard-coded uint constants against. Referenced by that TEST project only — the drivers
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
+6
View File
@@ -29,6 +29,9 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
@@ -90,6 +93,9 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
+12
View File
@@ -272,6 +272,12 @@ services:
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-1"
@@ -391,6 +397,12 @@ services:
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-2"
+39
View File
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
### Post-(re)load condition-node re-assert (#487)
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
never come.
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
Four properties are load-bearing:
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
machinery — or a future refactor — to mistake for a transition.
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
alarm's display name — and writes nothing at all on the common all-normal deploy.
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
these writes.
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
## Source integration
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
{
[Theory]
[InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
[InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
{
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value);
}
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
- `ServerTimestampUtc`: `DateTime.UtcNow`.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
**Step 4: run, expect PASS.**
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` (state word; §3.6) |
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
### 3.4 `IReadable` — `/current`
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
and the decisions worth knowing before touching it:
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
to the string the catalog returned, so the text quoted into a poll query is one this driver read
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
match under a case-sensitive collation is refused rather than guessed, because picking one would
publish another column's data under the operator's node.
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
hunting a typo has to see what they wrote.
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
but stays in the *authored* table, so it still materializes as a node and reads
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
drop is logged at Warning with the tag, the field and the reason.
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
them would serve a confidently-empty address space and send the operator hunting typos that do not
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
wall-clock bound as the liveness check (R2-01 / STAB-14).
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
message pointing at the fix — expose the data through a view in the connected database.
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
this keeps the factory shape unchanged.
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it
in display/logging and flags it dev-only.
- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
sees them). The key is matched case-insensitively, and the error text never echoes the value.
- **Never log the resolved connection string.** Log only provider + server host + database name.
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
Kerberos) so no password transits config at all.
+8 -1
View File
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
(a projection also carries the definition's severity, the resolved message template, and the last-observed
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
nodes at all. The original deferral text follows for context.
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
@@ -41,9 +41,100 @@ public static class DraftValidator
ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
return errors;
}
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// </summary>
/// <remarks>
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
/// Discarding a secret on read is not the same as refusing to store it.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
/// device blob lands in exactly the same place.</para>
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
/// log and the audit trail; repeating the offending string there would leak the very credential the
/// rule is refusing to store.</para>
/// </remarks>
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
{
const string ForbiddenKey = "connectionString";
var sqlInstanceIds = draft.DriverInstances
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
.Select(d => d.DriverInstanceId)
.ToHashSet(StringComparer.Ordinal);
if (sqlInstanceIds.Count == 0) return;
foreach (var d in draft.DriverInstances)
{
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
"from the environment / secret store at Initialize; a literal connection string here would be " +
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
"Remove the key and set 'connectionStringRef'.",
d.DriverInstanceId));
}
foreach (var dev in draft.Devices)
{
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
dev.DeviceId));
}
}
/// <summary>
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
/// and shaping the config JSON is another rule's job.
/// </summary>
/// <param name="json">The config blob to inspect.</param>
/// <param name="key">The property name to look for.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
private static bool HasTopLevelKey(string? json, string key)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
foreach (var property in doc.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -53,6 +53,9 @@ public static class DriverTypeNames
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
public const string Calculation = "Calculation";
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
public const string Sql = "Sql";
/// <summary>
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored <c>DriverType</c>).
@@ -68,5 +71,6 @@ public static class DriverTypeNames
OpcUaClient,
Galaxy,
Calculation,
Sql,
];
}
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
/// <summary>
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
/// severity, the rendered message template and the last-observed input quality, i.e. everything
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
/// the next transition.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
/// transition to report), so the transition stream cannot carry the current state — that is
/// exactly why the caller needs this. Routing these through the emission path instead would
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
/// intentionally one the emission machinery cannot consume.
/// </para>
/// <para>
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
/// read publishes its own transition through the normal path.
/// </para>
/// </remarks>
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
{
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
foreach (var (alarmId, state) in _alarms)
{
projections.Add(new ScriptedAlarmProjection(
AlarmId: alarmId,
Severity: state.Definition.Severity,
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
Condition: state.Condition,
WorstInputStatusCode: LastWorstStatus(alarmId)));
}
return projections;
}
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the acknowledgment.</param>
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u);
/// <summary>
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
/// for a future refactor to mistake for a transition.
/// </summary>
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
/// <param name="Severity">The definition's severity bucket.</param>
/// <param name="Message">The message template resolved against the current value cache.</param>
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
public sealed record ScriptedAlarmProjection(
string AlarmId,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
uint WorstInputStatusCode);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge.
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public static class AbCipStatusMapper
{
public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u;
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
public static class AbLegacyStatusMapper
{
public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u;
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
{
rejectedTcs.TrySetResult(new DataValueSnapshot(
Value: null,
StatusCode: 0x80000000u, // Bad
StatusCode: StatusCodeMap.Bad,
SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow));
}
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
{
tcs.TrySetResult(new DataValueSnapshot(
Value: null,
StatusCode: 0x800B0000u, // BadTimeout
StatusCode: StatusCodeMap.BadTimeout,
SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow));
}
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
{
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
//
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
// originally written as though the DA byte could be shifted into the substatus position, which
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
// against the pinned SDK's Opc.Ua.StatusCodes.
public const uint Good = 0x00000000u;
public const uint GoodLocalOverride = 0x00D80000u;
public const uint GoodLocalOverride = 0x00960000u;
public const uint Uncertain = 0x40000000u;
public const uint UncertainLastUsableValue = 0x40A40000u;
public const uint UncertainSensorNotAccurate = 0x408D0000u;
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
public const uint UncertainSubNormal = 0x408F0000u;
public const uint UncertainLastUsableValue = 0x40900000u;
public const uint UncertainSensorNotAccurate = 0x40930000u;
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
public const uint UncertainSubNormal = 0x40950000u;
public const uint Bad = 0x80000000u;
public const uint BadConfigurationError = 0x80890000u;
public const uint BadNotConnected = 0x808A0000u;
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u;
/// <summary>
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
/// </summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
public static uint Map(byte q) => q switch
{
// Good family (192+)
192 => 0x00000000u, // Good
216 => 0x00D80000u, // Good_LocalOverride
192 => HistorianStatusCodes.Good,
216 => HistorianStatusCodes.GoodLocalOverride,
// Uncertain family (64-191)
64 => 0x40000000u, // Uncertain
68 => 0x40900000u, // Uncertain_LastUsableValue
80 => 0x40930000u, // Uncertain_SensorNotAccurate
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
88 => 0x40950000u, // Uncertain_SubNormal
64 => HistorianStatusCodes.Uncertain,
68 => HistorianStatusCodes.UncertainLastUsableValue,
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
88 => HistorianStatusCodes.UncertainSubNormal,
// Bad family (0-63)
0 => 0x80000000u, // Bad
4 => 0x80890000u, // Bad_ConfigurationError
8 => 0x808A0000u, // Bad_NotConnected
12 => 0x808B0000u, // Bad_DeviceFailure
16 => 0x808C0000u, // Bad_SensorFailure
20 => 0x80050000u, // Bad_CommunicationError
24 => 0x808D0000u, // Bad_OutOfService
32 => 0x80320000u, // Bad_WaitingForInitialData
0 => HistorianStatusCodes.Bad,
4 => HistorianStatusCodes.BadConfigurationError,
8 => HistorianStatusCodes.BadNotConnected,
12 => HistorianStatusCodes.BadDeviceFailure,
16 => HistorianStatusCodes.BadSensorFailure,
20 => HistorianStatusCodes.BadCommunicationError,
24 => HistorianStatusCodes.BadOutOfService,
32 => HistorianStatusCodes.BadWaitingForInitialData,
// Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u,
_ when q >= 64 => 0x40000000u,
_ => 0x80000000u,
_ when q >= 192 => HistorianStatusCodes.Good,
_ when q >= 64 => HistorianStatusCodes.Uncertain,
_ => HistorianStatusCodes.Bad,
};
}
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <summary>
/// The OPC UA status codes this driver publishes, as named constants.
/// </summary>
/// <remarks>
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
/// A literal written at a call site is invisible to that guard, which is exactly how
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
/// </remarks>
internal static class HistorianStatusCodes
{
// ---- Good family ----
/// <summary>The value is good; no qualification.</summary>
public const uint Good = 0x00000000u;
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
public const uint GoodLocalOverride = 0x00960000u;
// ---- Uncertain family ----
/// <summary>The value is uncertain; no specific reason.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
public const uint UncertainLastUsableValue = 0x40900000u;
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
public const uint UncertainSensorNotAccurate = 0x40930000u;
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
public const uint UncertainSubNormal = 0x40950000u;
// ---- Bad family ----
/// <summary>The value is bad; no specific reason.</summary>
public const uint Bad = 0x80000000u;
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>The source is not connected (OPC DA quality 8).</summary>
public const uint BadNotConnected = 0x808A0000u;
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
public const uint BadSensorFailure = 0x808C0000u;
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The source is out of service (OPC DA quality 24).</summary>
public const uint BadOutOfService = 0x808D0000u;
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The historian returned no data for the requested tag/interval. Distinct from a transport
/// failure: the query succeeded and the answer was empty.
/// </summary>
public const uint BadNoData = 0x809B0000u;
}
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </summary>
internal static class SampleMapper
{
private const uint StatusGood = 0x00000000u;
private const uint StatusBadNoData = 0x800E0000u;
private const uint StatusGood = HistorianStatusCodes.Good;
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
private const byte GoodQualityFloor = 192;
@@ -0,0 +1,201 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
public enum SqlBrowseNodeKind
{
/// <summary>A schema — the browse root level.</summary>
Schema,
/// <summary>A table or view inside a schema.</summary>
Table,
/// <summary>A column of a table or view — the terminal (leaf) level.</summary>
Column,
}
/// <summary>A decoded browse <c>NodeId</c>.</summary>
/// <param name="Kind">Which catalog level this reference addresses.</param>
/// <param name="Schema">The schema name. Always present.</param>
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
public readonly record struct SqlBrowseNodeRef(
SqlBrowseNodeKind Kind,
string Schema,
string? Table,
string? Column);
/// <summary>
/// Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
/// on expand / attribute fetch / column commit.
/// <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
/// <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
/// inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
/// both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
/// <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
/// + table <c>b</c>, and a table named <c>x|y</c> loses its second half entirely. That is not a cosmetic
/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
/// </para>
/// <para><b>The encoding.</b> <c>&lt;kind&gt;:&lt;part&gt;[|&lt;part&gt;…]</c>, where <c>kind</c> is one
/// of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
/// can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
/// <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
/// readable (<c>column:dbo|TagValues|num_value</c>). The kind prefix carries the arity, so decoding never
/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is
/// rejected rather than truncated or padded.</para>
/// <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
/// column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. Encode and decode
/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
/// exists to prevent.</para>
/// </summary>
public static class SqlBrowseNodeId
{
private const char Separator = '|';
private const char Escape = '\\';
private const char KindTerminator = ':';
private const string SchemaKind = "schema";
private const string TableKind = "table";
private const string ColumnKind = "column";
/// <summary>Encodes a schema-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
public static string ForSchema(string schema) => Encode(SchemaKind, schema);
/// <summary>Encodes a table-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
/// <summary>Encodes a column-level (leaf) NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <param name="column">The column name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
public static string ForColumn(string schema, string table, string column) =>
Encode(ColumnKind, schema, table, column);
/// <summary>Attempts to decode a NodeId.</summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
/// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
{
reference = default;
if (string.IsNullOrWhiteSpace(nodeId)) return false;
var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
if (terminator <= 0) return false;
var kind = nodeId[..terminator];
if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
if (parts.Exists(string.IsNullOrWhiteSpace)) return false;
switch (kind)
{
case SchemaKind when parts.Count == 1:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
return true;
case TableKind when parts.Count == 2:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
return true;
case ColumnKind when parts.Count == 3:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
return true;
default:
return false;
}
}
/// <summary>
/// Decodes a NodeId, throwing when it is malformed.
/// <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
/// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
/// </summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <returns>The decoded reference.</returns>
/// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
public static SqlBrowseNodeRef Parse(string? nodeId)
{
if (TryParse(nodeId, out var reference)) return reference;
throw new ArgumentException(
"Not a SQL schema-browse node. Expected 'schema:<schema>', 'table:<schema>|<table>' or " +
"'column:<schema>|<table>|<column>'. Re-open the browser and expand from the root.",
nameof(nodeId));
}
private static string Encode(string kind, params string[] parts)
{
var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
builder.Append(kind).Append(KindTerminator);
for (var i = 0; i < parts.Length; i++)
{
if (string.IsNullOrWhiteSpace(parts[i]))
{
throw new ArgumentException(
"A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
}
if (i > 0) builder.Append(Separator);
AppendEscaped(builder, parts[i]);
}
return builder.ToString();
}
private static void AppendEscaped(StringBuilder builder, string part)
{
foreach (var ch in part)
{
if (ch is Separator or Escape) builder.Append(Escape);
builder.Append(ch);
}
}
/// <summary>
/// Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
/// <c>false</c> on a dangling trailing escape, which cannot be produced by
/// <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
/// </summary>
private static bool TrySplit(string payload, out List<string> parts)
{
parts = [];
var current = new StringBuilder(payload.Length);
var escaped = false;
foreach (var ch in payload)
{
if (escaped)
{
current.Append(ch);
escaped = false;
continue;
}
switch (ch)
{
case Escape:
escaped = true;
break;
case Separator:
parts.Add(current.ToString());
current.Clear();
break;
default:
current.Append(ch);
break;
}
}
if (escaped) return false;
parts.Add(current.ToString());
return true;
}
}
@@ -0,0 +1,304 @@
using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
/// column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
/// two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
/// the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
/// never needs an identifier in text.</para>
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
/// <see cref="DisposeAsync"/>.</b> It does not open one, and it never re-opens a closed one. The handoff
/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
/// if the session did not close it, nothing would, and every reaped picker would leak a pooled
/// connection.</para>
/// <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
/// 20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); this session's job is simply to
/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
/// independent deadline here would only produce two competing, differently-worded failures.</para>
/// </summary>
internal sealed class SqlBrowseSession : IBrowseSession
{
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
private const string TableTypeColumn = "TABLE_TYPE";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Column alias carrying the SQL type family name.</summary>
private const string DataTypeColumn = "DATA_TYPE";
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
/// <summary>
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
/// </summary>
private const string ReadOnlySecurityClass = "ViewOnly";
private readonly DbConnection _connection;
private readonly ISqlDialect _dialect;
/// <summary>
/// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
/// the AdminUI tree happily fires several expands at once when an operator clicks quickly.
/// </summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
/// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
/// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
}
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <inheritdoc />
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <inheritdoc />
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
var schemas = await QueryAsync(
_dialect.ListSchemasSql,
static _ => { },
static reader => ReadString(reader, SchemaColumn),
cancellationToken).ConfigureAwait(false);
var nodes = new List<BrowseNode>(schemas.Count);
foreach (var schema in schemas)
{
if (string.IsNullOrWhiteSpace(schema)) continue;
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForSchema(schema),
DisplayName: schema,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: true));
}
return nodes;
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var reference = SqlBrowseNodeId.Parse(nodeId);
switch (reference.Kind)
{
case SqlBrowseNodeKind.Schema:
return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
case SqlBrowseNodeKind.Table:
return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
.ConfigureAwait(false);
default:
// A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
// it has re-read the node's Kind.
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<BrowseNode>();
}
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(
string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var reference = SqlBrowseNodeId.Parse(nodeId);
if (reference.Kind != SqlBrowseNodeKind.Column)
{
// Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
// uniform and returns empty for every node.
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<AttributeInfo>();
}
var columns = await ReadColumnsAsync(
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
// that legitimately carries both "Value" and "value".
foreach (var column in columns)
{
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
return
[
new AttributeInfo(
Name: column.Name,
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
IsArray: false,
SecurityClass: ReadOnlySecurityClass),
];
}
// The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
// the honest answer; the picker simply has nothing to prefill.
return Array.Empty<AttributeInfo>();
}
/// <summary>
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await _connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the connection may already be broken or closed. */ }
try { _gate.Dispose(); }
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
}
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
{
var rows = await QueryAsync(
_dialect.ListTablesSql,
command => Bind(command, "@schema", schema),
static reader => (
Name: ReadString(reader, TableNameColumn),
Type: ReadOptionalString(reader, TableTypeColumn)),
ct).ConfigureAwait(false);
var nodes = new List<BrowseNode>(rows.Count);
foreach (var (name, type) in rows)
{
if (string.IsNullOrWhiteSpace(name)) continue;
var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForTable(schema, name),
// The label is decorated, never the NodeId — a view and a table of the same name in the same
// schema cannot exist, so the suffix is presentation only.
DisplayName: isView ? $"{name} (view)" : name,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: true));
}
return nodes;
}
private async Task<IReadOnlyList<BrowseNode>> ExpandTableAsync(
string schema, string table, CancellationToken ct)
{
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
var nodes = new List<BrowseNode>(columns.Count);
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
DisplayName: column.Name,
Kind: BrowseNodeKind.Leaf,
HasChildrenHint: false));
}
return nodes;
}
/// <summary>
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
/// relation, and neither is a reason to fail an operator's click.
/// </summary>
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
string schema, string table, CancellationToken ct) =>
QueryAsync(
_dialect.ListColumnsSql,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
ct);
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
/// </summary>
private async Task<List<T>> QueryAsync<T>(
string sql,
Action<DbCommand> bind,
Func<DbDataReader, T> project,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var results = new List<T>();
await using var command = _connection.CreateCommand();
command.CommandText = sql;
bind(command);
await using var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(project(reader));
LastUsedUtc = DateTime.UtcNow;
return results;
}
finally
{
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
// ignore and must not mask the real result.
try { _gate.Release(); }
catch (ObjectDisposedException) { }
}
}
/// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
private static string ReadString(DbDataReader reader, string columnName) =>
ReadOptionalString(reader, columnName) ?? string.Empty;
private static string? ReadOptionalString(DbDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
}
}
@@ -0,0 +1,401 @@
using System.Data.Common;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>
/// Opens a <b>transient</b> database connection from form-supplied JSON for the AdminUI schema picker,
/// and hands it to a <see cref="SqlBrowseSession"/>. The AdminUI's <c>BrowseSessionRegistry</c> (its idle
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
/// registry of its own.
/// <para><b>Two ways to name the database, and only one of them is persisted.</b>
/// <c>connectionStringRef</c> is the deployed form: a name resolved <em>in the AdminUI process</em> from
/// the environment as <c>Sql__ConnectionStrings__&lt;ref&gt;</c> (design §4.4), so a deployed artifact
/// never carries credentials. <c>connectionString</c> is an ad-hoc, <b>session-only</b> literal an
/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
/// anywhere.</para>
/// <para><b>Precedence: a pasted literal wins over a ref</b>, and the ref is then not even read. The
/// literal cannot arrive from a persisted blob — <see cref="SqlDriverConfigDto"/> has no such property, so
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
/// the override is visible rather than silent.</para>
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
/// own errors, every failure on the open path goes through <see cref="Sanitize"/> before it leaves this
/// class.</para>
/// </summary>
public sealed class SqlDriverBrowser : IDriverBrowser
{
/// <summary>
/// Prefix of the environment variable a <c>connectionStringRef</c> resolves through — the
/// double-underscore form .NET configuration uses for the <c>Sql:ConnectionStrings:&lt;ref&gt;</c>
/// key path.
/// </summary>
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
/// <summary>
/// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
/// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
/// picker entirely.
/// </summary>
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
/// <summary>
/// Connection-string keys whose <em>values</em> are redacted out of any provider error text. The
/// whole connection string is redacted unconditionally; these are the parts that could survive as a
/// fragment. Server / database names are deliberately <b>not</b> redacted — "server X was not found"
/// is the diagnostic the operator needs, and a hostname is not a credential.
/// </summary>
private static readonly string[] CredentialKeys =
[
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
];
/// <summary>
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
/// knobs are authored as their <em>names</em>, so the browser parses a given <c>DriverConfig</c> blob
/// exactly as the runtime factory does. <see cref="JsonStringEnumConverter"/> also accepts ordinals,
/// so a numeric config still binds.
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly Func<SqlProvider, ISqlDialect?> _dialectSelector;
private readonly Func<string, string?> _environmentReader;
private readonly ILogger<SqlDriverBrowser> _logger;
/// <summary>Creates a browser. Every dependency has a production default, so DI may construct it bare.</summary>
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
/// provider gets added without touching this class.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
/// Injectable because environment variables are process-global, and a test that sets one races every
/// other test in the assembly.
/// </param>
/// <param name="logger">Optional; defaults to <see cref="NullLogger{T}"/>.</param>
public SqlDriverBrowser(
Func<SqlProvider, ISqlDialect?>? dialectSelector = null,
Func<string, string?>? environmentReader = null,
ILogger<SqlDriverBrowser>? logger = null)
{
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
_logger = logger ?? NullLogger<SqlDriverBrowser>.Instance;
}
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name, e.g. <c>Sql__ConnectionStrings__MesStaging</c>.</returns>
public static string EnvironmentVariableFor(string connectionStringRef) =>
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
/// the caller reports it as unavailable rather than crashing on a null dialect.
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
/// a <c>connectionStringRef</c> nor a literal, resolves a ref that is not set in the environment, or
/// the connection could not be opened.
/// </exception>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(configJson))
{
throw new InvalidOperationException(
"The Sql browser requires a driver configuration; none was supplied.");
}
var literal = ReadPastedConnectionString(configJson);
var config = Deserialize(configJson, literal);
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
// named, and answering it before touching the environment keeps the two failures from masking.
var dialect = _dialectSelector(config.Provider)
?? throw new InvalidOperationException(
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ $"'{SqlProvider.SqlServer}' only.");
var reference = config.ConnectionStringRef;
var connectionString = ResolveConnectionString(literal, reference);
_logger.LogInformation(
"AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
config.Provider,
DescribeSource(literal, reference));
return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Opens one transient connection and hands ownership to the session. <b>The session closes the
/// connection on its own disposal</b>, so the only disposal this method owns is the failure path —
/// disposing on success would double-close and leave the picker with a dead session.
/// </summary>
private static async Task<IBrowseSession> OpenSessionAsync(
ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException(
$"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
try
{
connection.ConnectionString = connectionString;
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(ConnectBudget);
await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
return new SqlBrowseSession(connection, dialect);
}
catch (Exception ex)
{
try { await connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the original failure is the useful one. */ }
if (ex is OperationCanceledException) throw;
throw Sanitize(ex, connectionString);
}
}
/// <summary>
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
/// form field), never as an empty connection string.
/// </summary>
private string ResolveConnectionString(string? literal, string? reference)
{
if (!string.IsNullOrWhiteSpace(literal))
{
if (!string.IsNullOrWhiteSpace(reference))
{
_logger.LogWarning(
"Sql browse: a pasted connection string was supplied alongside connectionStringRef "
+ "'{Ref}'. The pasted value wins for this session only and is not persisted.",
reference);
}
return literal;
}
if (string.IsNullOrWhiteSpace(reference))
{
throw new InvalidOperationException(
"The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
+ $"from the environment as '{ConnectionStringEnvironmentPrefix}<ref>', or paste a "
+ "connection string into 'connectionString' for an ad-hoc browse.");
}
var variable = EnvironmentVariableFor(reference);
var resolved = _environmentReader(variable);
if (string.IsNullOrWhiteSpace(resolved))
{
// Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
// move is to set this one name on the AdminUI host.
throw new InvalidOperationException(
$"connectionStringRef '{reference}' does not resolve: environment variable "
+ $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
+ "connection strings in its own process), or paste a connection string for an ad-hoc "
+ "browse.");
}
return resolved;
}
/// <summary>How the connection string was obtained, for the log. Carries no connection text.</summary>
private static string DescribeSource(string? literal, string? reference) =>
!string.IsNullOrWhiteSpace(literal)
? "a pasted literal (session-only, not persisted)"
: $"connectionStringRef '{reference}'";
/// <summary>
/// Pulls the ad-hoc <c>connectionString</c> literal out of the raw JSON. It is read here rather than
/// off the DTO because <see cref="SqlDriverConfigDto"/> deliberately has no such property — the
/// persisted contract must not be able to carry a credential — and the DTO's
/// <see cref="JsonUnmappedMemberHandling.Skip"/> would silently drop it.
/// </summary>
/// <returns>The literal, or <see langword="null"/> when absent, blank, or not a JSON string.</returns>
private static string? ReadPastedConnectionString(string configJson)
{
using var document = ParseDocument(configJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration must be a JSON object.");
}
foreach (var property in document.RootElement.EnumerateObject())
{
if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
continue;
if (property.Value.ValueKind != JsonValueKind.String) return null;
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
/// <summary>
/// Parses the raw configuration. The <see cref="JsonException"/> is <b>not</b> attached or echoed:
/// at this point nothing has been extracted, so there is no known secret to redact against, and the
/// malformed text may itself be a pasted connection string.
/// </summary>
private static JsonDocument ParseDocument(string configJson)
{
try
{
return JsonDocument.Parse(configJson);
}
catch (JsonException)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration is not valid JSON. (The parser's message is "
+ "withheld because the malformed text may carry a connection string.)");
}
}
/// <summary>
/// Binds the typed configuration. A bind failure (e.g. an unknown <c>provider</c> name) is reported
/// with the parser's own message redacted against the literal, which is known by now.
/// </summary>
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(configJson, JsonOpts)
?? throw new InvalidOperationException(
"The Sql browser's driver configuration deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration could not be bound: "
+ Redact(ex.Message, CollectSecrets(literal)));
}
}
/// <summary>
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
/// <para><b>Live-probed, not assumed.</b> <c>Microsoft.Data.SqlClient</c> and
/// <c>Microsoft.Data.Sqlite</c> keep connection-string <em>values</em> out of their
/// <c>SqlException</c>/<c>SqliteException</c> text (a failed connect says "the server was not found",
/// never what it was handed). Their connection-string <b>parser</b> is the exception: it reports an
/// unrecognised keyword by quoting it, and a value carrying an unquoted <c>;</c> — legal inside a
/// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
/// keyword and echoed verbatim. <c>Password=Sup3r;SecretTail</c> yields
/// <c>Keyword not supported: 'secrettail;connect timeout'.</c> — the credential, in the message,
/// lower-cased and therefore invisible to a naive ordinal search for the value.</para>
/// <para>So the parser's message (an <see cref="ArgumentException"/>) is <b>never</b> surfaced. Every
/// other failure is additionally passed through the substring redactor as a backstop, and when that
/// fires anywhere in the exception <em>chain</em> the inner exception is dropped too — keeping it
/// would re-expose through <c>ToString()</c> exactly what the message just removed.</para>
/// </summary>
private static InvalidOperationException Sanitize(Exception ex, string connectionString)
{
if (ex is ArgumentException)
{
return new InvalidOperationException(
"The Sql browser could not open a connection: the connection string is malformed, or "
+ "carries a keyword this provider does not support. (The provider's own message is "
+ "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
+ "or '=' inside a value such as the password.)");
}
var secrets = CollectSecrets(connectionString);
var leaked = ContainsAny(ex.ToString(), secrets);
var reason = Redact(ex.Message, secrets);
var message = leaked
? "The Sql browser could not open a connection: " + reason
+ " (the provider's error echoed the connection string, so it was redacted and the inner "
+ "exception withheld)"
: "The Sql browser could not open a connection: " + reason;
return new InvalidOperationException(message, leaked ? null : ex);
}
/// <summary>
/// The substrings that must never leave this class: the whole connection string, and the values of
/// its credential-bearing keys (which could surface on their own).
/// </summary>
private static List<string> CollectSecrets(string? connectionString)
{
var secrets = new List<string>();
if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
secrets.Add(connectionString);
try
{
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
foreach (var key in CredentialKeys)
{
if (!builder.TryGetValue(key, out var value)) continue;
var text = value?.ToString();
if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
}
}
catch (ArgumentException)
{
// A malformed connection string has no parseable parts; the whole-string entry still stands.
}
// Longest first, so redacting a short credential value cannot chop a longer secret into
// unredactable halves.
secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
return secrets;
}
private static bool ContainsAny(string text, List<string> secrets)
{
foreach (var secret in secrets)
{
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
private static string Redact(string text, List<string> secrets)
{
foreach (var secret in secrets)
{
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
}
return text;
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The <c>DriverConfig</c> blob shape for a <c>Sql</c> driver instance (design §5.1). Deserialised by the
/// driver factory, which applies the documented defaults for every absent (null) field and maps the result
/// onto the driver's typed options.
/// <para><b>No connection string lives here.</b> Only <see cref="ConnectionStringRef"/> — a name resolved at
/// Initialize from the environment / secret store (e.g. <c>Sql__ConnectionStrings__MesStaging</c>) — so a
/// deployed artifact never carries database credentials.</para>
/// <para>Enum-valued fields are enum-typed (not strings) so a <c>JsonStringEnumConverter</c> round-trips
/// their <em>names</em> rather than their ordinals. The converter is applied by the factory's
/// <c>JsonSerializerOptions</c> (Task 9), not by an attribute on this DTO — mirroring the driver
/// enum-serialization trap that bit the AdminUI driver pages.</para>
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
/// Name of the connection string to resolve from the environment / secret store at Initialize —
/// <b>never</b> the connection string itself.
/// </summary>
public string? ConnectionStringRef { get; init; }
/// <summary>Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default.</summary>
public TimeSpan? DefaultPollInterval { get; init; }
/// <summary>
/// Per-query wall-clock deadline enforced client-side (a breach surfaces <c>BadTimeout</c>). Must be
/// greater than <see cref="CommandTimeout"/>, which is only the server-side backstop (design §8.3).
/// </summary>
public TimeSpan? OperationTimeout { get; init; }
/// <summary>ADO.NET <c>CommandTimeout</c> backstop (seconds granularity server-side).</summary>
public TimeSpan? CommandTimeout { get; init; }
/// <summary>Cap on concurrently executing group queries — the connection-pool guard.</summary>
public int? MaxConcurrentGroups { get; init; }
/// <summary>When <see langword="true"/>, a NULL cell publishes Bad rather than the default Uncertain.</summary>
public bool? NullIsBad { get; init; }
/// <summary>
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
/// an operator who believes writes are enabled can.
/// </summary>
public bool? AllowWrites { get; init; }
/// <summary>
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
/// the driver <c>TagConfig</c> blob, the same shape every other driver receives. The factory copies
/// these onto the driver's options and the driver maps each through
/// <see cref="SqlEquipmentTagParser.TryParse"/> at Initialize.
/// <para>A SQL source has no tag-discovery protocol, so this list is the complete set of tags the
/// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will
/// find its tags later.</para>
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
}
@@ -0,0 +1,131 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Pure mapper from an authored raw tag's <c>TagConfig</c> JSON (design §5.2 / §5.3) to a
/// <see cref="SqlTagDefinition"/>, mirroring <c>ModbusTagDefinitionFactory.FromTagConfig</c>. The blob is
/// recognised by a leading <c>{</c> plus a <c>"driver":"Sql"</c> marker or a <c>"model"</c> discriminator;
/// the <c>model</c> and <c>type</c> enums are read with
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so a present-but-invalid (typo'd) value rejects
/// the whole tag — the driver then surfaces <c>BadNodeIdUnknown</c> instead of silently defaulting to a
/// different model and publishing a misleading <c>Good</c> (R2-11).
/// <para><b>No SQL is built here.</b> The parser only captures strings; the reader binds value fields as
/// <c>DbParameter</c>s and validates + quotes identifier fields. Tag input is never concatenated into a
/// command text, so a hostile <c>keyValue</c> is stored — and must be stored — verbatim.</para>
/// </summary>
public static class SqlEquipmentTagParser
{
/// <summary>
/// Maps an authored <c>TagConfig</c> blob to a typed definition keyed by <paramref name="rawPath"/>.
/// Returns <see langword="false"/> — never throws — for anything the driver must not serve: a
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd <c>model</c> or
/// <c>type</c> enum, a missing model-required field, or the P3-deferred
/// <see cref="SqlTagModel.Query"/> model.
/// </summary>
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(reference)) return false;
if (reference.TrimStart().FirstOrDefault() != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// Recognition: either the explicit driver marker or the model discriminator must be present,
// so another driver's TagConfig blob is not mistaken for a Sql one.
var driver = ReadString(root, "driver");
var hasModel = root.TryGetProperty("model", out _);
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
// Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
DriverDataType? declaredType =
root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
? type
: null;
var table = ReadString(root, "table");
if (string.IsNullOrWhiteSpace(table)) return false;
var timestampColumn = ReadString(root, "timestampColumn");
switch (model)
{
case SqlTagModel.KeyValue:
{
var keyColumn = ReadString(root, "keyColumn");
var keyValue = ReadString(root, "keyValue");
var valueColumn = ReadString(root, "valueColumn");
if (string.IsNullOrWhiteSpace(keyColumn)
|| string.IsNullOrWhiteSpace(valueColumn)
|| keyValue is null)
return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
TimestampColumn: timestampColumn, DeclaredType: declaredType);
return true;
}
case SqlTagModel.WideRow:
{
var columnName = ReadString(root, "columnName");
if (string.IsNullOrWhiteSpace(columnName)) return false;
string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
{
selectorColumn = ReadString(sel, "whereColumn");
selectorValue = ReadScalar(sel, "whereValue");
topByTimestamp = ReadString(sel, "topByTimestamp");
}
// A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
TimestampColumn: timestampColumn, ColumnName: columnName,
RowSelectorColumn: hasWherePair ? selectorColumn : null,
RowSelectorValue: hasWherePair ? selectorValue : null,
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
DeclaredType: declaredType);
return true;
}
default:
// SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
// the runtime cannot read.
return false;
}
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string.</summary>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>
/// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
/// boolean yields its raw token — so an authored <c>whereValue</c> of either shape is captured
/// verbatim for later parameter binding. Null when absent or not a scalar.
/// </summary>
private static string? ReadScalar(JsonElement o, string name)
{
if (!o.TryGetProperty(name, out var e)) return null;
return e.ValueKind switch
{
JsonValueKind.String => e.GetString(),
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
_ => null,
};
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
Oracle,
}
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel
{
/// <summary>
/// Key-value (EAV) table: one row per tag, selected by <c>keyColumn = keyValue</c>, value read from
/// <c>valueColumn</c>.
/// </summary>
KeyValue,
/// <summary>
/// Wide row: one row holds many signals as columns; the tag names its <c>columnName</c> and the row is
/// picked by a row selector (a <c>whereColumn</c>/<c>whereValue</c> pair, or newest-by-timestamp).
/// </summary>
WideRow,
/// <summary>Named arbitrary-SELECT query (design §5.4). <b>Deferred to P3 — not accepted by v1's parser.</b></summary>
Query,
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's <c>TagConfig</c> blob
/// (design §5.2 / §5.3), produced by <see cref="SqlEquipmentTagParser.TryParse"/>.
/// <para><b>Every string here is captured verbatim from the authored blob and is NEVER concatenated
/// into SQL.</b> Value-bearing fields (<see cref="KeyValue"/>, <see cref="RowSelectorValue"/>) are bound
/// as <c>DbParameter</c>s by the reader; identifier-bearing fields (<see cref="Table"/>,
/// <see cref="KeyColumn"/>, <see cref="ValueColumn"/>, <see cref="TimestampColumn"/>,
/// <see cref="ColumnName"/>, <see cref="RowSelectorColumn"/>,
/// <see cref="RowSelectorTopByTimestamp"/>) must be validated against <c>INFORMATION_SCHEMA</c> and
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
/// legitimate values, so the parser deliberately does not.</para>
/// </summary>
/// <param name="Name">
/// The tag's identity — its <b>RawPath</b>, exactly as handed to the parser. The driver's forward
/// router keys published values on this, so it must never be derived from the blob's contents.
/// </param>
/// <param name="Model">Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.</param>
/// <param name="Table">The table or view to read (e.g. <c>dbo.TagValues</c>).</param>
/// <param name="KeyColumn"><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</param>
/// <param name="KeyValue"><see cref="SqlTagModel.KeyValue"/>: this tag's key — <b>bound</b>, never interpolated.</param>
/// <param name="ValueColumn"><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</param>
/// <param name="TimestampColumn">Optional source-timestamp column; absent ⇒ the driver stamps the poll time.</param>
/// <param name="ColumnName"><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</param>
/// <param name="RowSelectorColumn"><see cref="SqlTagModel.WideRow"/>: the <c>whereColumn</c> that picks the row.</param>
/// <param name="RowSelectorValue"><see cref="SqlTagModel.WideRow"/>: the <c>whereValue</c> — <b>bound</b>, never interpolated.</param>
/// <param name="RowSelectorTopByTimestamp"><see cref="SqlTagModel.WideRow"/>: newest-row selector — the timestamp column to order by, when no where-pair is authored.</param>
/// <param name="DeclaredType">
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
/// driver infers the type from the result set's column metadata.
/// </param>
public sealed record SqlTagDefinition(
string Name,
SqlTagModel Model,
string Table,
string? KeyColumn = null,
string? KeyValue = null,
string? ValueColumn = null,
string? TimestampColumn = null,
string? ColumnName = null,
string? RowSelectorColumn = null,
string? RowSelectorValue = null,
string? RowSelectorTopByTimestamp = null,
DriverDataType? DeclaredType = null);
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,123 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
/// connections, commands, parameters and readers; they do <b>not</b> abstract the three things that differ
/// per backend — <b>identifier quoting</b>, the <b>metadata-catalog SQL</b>, and <b>row-limit syntax</b>.
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
/// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
/// <see cref="DbProviderFactory"/>).</para>
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
SqlProvider Provider { get; }
/// <summary>
/// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the
/// abstract base type so consumers never bind to a concrete provider package.
/// </summary>
DbProviderFactory Factory { get; }
/// <summary>
/// Quotes <b>one</b> identifier part so it can be safely embedded in a command text, escaping the
/// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather
/// than emitting it.
/// <para><b>One part only.</b> A multi-part name such as <c>dbo.TagValues</c> must be split by the
/// caller and each part quoted separately; passing the dotted form yields a single (nonexistent)
/// identifier — safe, but not what the caller meant.</para>
/// </summary>
/// <param name="ident">The bare, unquoted identifier — as returned by the catalog, not pre-quoted.</param>
/// <returns>The quoted identifier, ready to concatenate into a command text.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
string QuoteIdentifier(string ident);
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
string LivenessSql { get; }
/// <summary>
/// The fragment that goes <b>immediately after <c>SELECT</c></b> to limit a statement to one row —
/// T-SQL's <c>"TOP 1 "</c>. Empty for a dialect that spells the limit at the end of the statement (see
/// <see cref="SingleRowLimitSuffix"/>).
/// <para><b>Include the trailing space</b> when non-empty: the planner concatenates
/// <c>"SELECT " + SingleRowLimitPrefix + columns</c> with no separator of its own, so an empty fragment
/// must leave the statement byte-identical to an unlimited one.</para>
/// </summary>
/// <remarks>
/// <para><b>Why a prefix/suffix pair rather than one <c>ApplyRowLimit(sql, n)</c> method.</b> The two
/// spellings sit at opposite ends of the statement — SQL Server writes
/// <c>SELECT TOP 1 … ORDER BY … DESC</c>, while SQLite/Postgres/MySQL write
/// <c>SELECT … ORDER BY … DESC LIMIT 1</c>. A single method taking the SELECT list could only serve the
/// prefix position; a single method taking the whole statement would move statement assembly out of the
/// planner and into every dialect. Two fragments keep the planner the sole author of statement shape
/// and let each dialect fill in the end it uses.</para>
/// <para><b>Why "single row" rather than a row count.</b> The only limit v1 emits is the wide-row
/// <c>topByTimestamp</c> selector's newest-row pick. Modelling an arbitrary <c>n</c> would be untested
/// surface, and <c>FETCH FIRST</c>/<c>ROWNUM</c> dialects need more than a substituted number anyway.
/// Widen this to a method when a feature actually needs <c>n &gt; 1</c>.</para>
/// </remarks>
string SingleRowLimitPrefix { get; }
/// <summary>
/// The fragment appended to the <b>very end</b> of a statement to limit it to one row —
/// <c>" LIMIT 1"</c> for SQLite/Postgres/MySQL. Empty for a dialect that limits after <c>SELECT</c>
/// (see <see cref="SingleRowLimitPrefix"/>).
/// <para><b>Include the leading space</b> when non-empty, for the same reason the prefix carries a
/// trailing one: the planner appends it directly to the finished statement.</para>
/// <para>Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
/// emits <b>both</b> unconditionally — a dialect needing both ends (or neither) is expressible without
/// touching the call site.</para>
/// </summary>
string SingleRowLimitSuffix { get; }
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; }
/// <summary>
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
/// — the same dialect resolves differently for two different logins.</para>
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
string ListTablesSql { get; }
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
string ListColumnsSql { get; }
/// <summary>
/// Folds a catalog data-type family name (as <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> reports it —
/// e.g. <c>nvarchar</c>, never <c>nvarchar(50)</c>) onto a <see cref="DriverDataType"/>.
/// <para>Must <b>never throw</b>: a browse over a table holding one exotic column must still render.
/// An unrecognised family falls back to <see cref="DriverDataType.String"/>.</para>
/// </summary>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
DriverDataType MapColumnType(string sqlDataType);
}
@@ -0,0 +1,137 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
/// allow-list design §8.1 requires identifiers to come from.
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
/// identifier text in an emitted query is a string the database gave us.</para>
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
/// asks.</para>
/// </summary>
public sealed class SqlCatalog
{
private readonly IReadOnlyList<string> _schemas;
/// <summary>Canonical schema → canonical table names in it.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
/// <summary>Constructs a catalog snapshot.</summary>
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
/// <param name="schemas">Every schema the connection can see.</param>
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
internal SqlCatalog(
string defaultSchema,
IReadOnlyList<string> schemas,
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
{
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
_columnsByTable = columnsByTable;
}
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
public string DefaultSchema { get; }
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
public bool TryResolveSchema(string? authored, out string canonical)
{
if (string.IsNullOrWhiteSpace(authored))
{
canonical = DefaultSchema;
return true;
}
return TryMatch(_schemas, authored, out canonical);
}
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
/// <param name="authored">The authored table or view name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
{
canonical = string.Empty;
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
&& TryMatch(tables, authored, out canonical);
}
/// <summary>Resolves an authored column name within a canonical relation.</summary>
/// <param name="canonicalSchema">The resolved schema.</param>
/// <param name="canonicalTable">The resolved table or view.</param>
/// <param name="authored">The authored column name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
public bool TryResolveColumn(
string canonicalSchema, string canonicalTable, string authored, out string canonical)
{
canonical = string.Empty;
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
&& TryMatch(columns, authored, out canonical);
}
/// <summary>The dictionary key for a canonical relation.</summary>
/// <param name="schema">The canonical schema.</param>
/// <param name="table">The canonical table.</param>
/// <returns>The composite key.</returns>
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
/// <summary>
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
/// </summary>
/// <remarks>
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
/// ordinally would reject configurations that have always been valid — a gate that breaks working
/// deployments is not defence in depth.</para>
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
/// such a config resolving to what the operator wrote, and refusing to choose between two
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
/// </remarks>
/// <param name="candidates">The catalog spellings to match against.</param>
/// <param name="authored">The authored name.</param>
/// <param name="canonical">The catalog's spelling, when matched.</param>
/// <returns><see langword="true"/> on an unambiguous match.</returns>
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
{
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
canonical = candidate;
return true;
}
canonical = string.Empty;
var matches = 0;
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
if (++matches > 1)
{
canonical = string.Empty;
return false;
}
canonical = candidate;
}
return matches == 1;
}
}
@@ -0,0 +1,241 @@
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
/// shape and the caller's tests can assert on the reason.
/// </summary>
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
/// <param name="Accepted">
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
/// <see cref="SqlGroupPlanner"/>.
/// </param>
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
public sealed record SqlCatalogGateResult(
IReadOnlyList<SqlTagDefinition> Accepted,
IReadOnlyList<SqlCatalogRejection> Rejected);
/// <summary>
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
/// </summary>
/// <remarks>
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
/// sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
/// than the whole story.</para>
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
/// must, or an operator has no way to find the typo.</para>
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
/// driver's table, so its RawPath resolves to nothing and it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
/// down" rule the tag-table build already follows.</para>
/// </remarks>
public static class SqlCatalogGate
{
/// <summary>
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
/// cannot see, so it cannot be allow-listed.
/// </summary>
private const int MaxNameParts = 2;
/// <summary>
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
/// with catalog-spelled identifiers and the rejected ones with reasons.
/// </summary>
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
/// <param name="catalog">The catalog snapshot to validate against.</param>
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
/// <returns>The accepted and rejected sets.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
public static SqlCatalogGateResult Apply(
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(definitions);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(dialect);
var accepted = new List<SqlTagDefinition>();
var rejected = new List<SqlCatalogRejection>();
foreach (var definition in definitions)
{
ArgumentNullException.ThrowIfNull(definition);
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
rejected.Add(rejection!);
}
return new SqlCatalogGateResult(accepted, rejected);
}
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
private static bool TryCanonicalize(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out SqlTagDefinition? canonical,
out SqlCatalogRejection? rejection)
{
canonical = null;
rejection = null;
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
return false;
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
// model does not use is null and simply skipped — the planner enforces which are required.
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var (field, authored) in new (string Field, string? Authored)[]
{
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
})
{
if (string.IsNullOrWhiteSpace(authored))
{
resolved[field] = authored;
continue;
}
if (!IsRenderableIdentifier(authored, dialect))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
"or Unicode format characters); the value is withheld from this message because it " +
"cannot be safely rendered");
return false;
}
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
"column under a case-sensitive collation)");
return false;
}
resolved[field] = column;
}
canonical = definition with
{
Table = SqlCatalog.QualifiedKey(schema, table),
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
};
return true;
}
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
private static bool TryResolveRelation(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out string schema,
out string table,
out SqlCatalogRejection? rejection)
{
schema = string.Empty;
table = string.Empty;
rejection = null;
const string Field = nameof(SqlTagDefinition.Table);
if (string.IsNullOrWhiteSpace(definition.Table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
"the tag has no 'table'; author the table or view to read");
return false;
}
var parts = definition.Table.Split('.');
if (parts.Length > MaxNameParts)
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
"validated against this connection's catalog, so a cross-database or linked-server name " +
"cannot be allow-listed; expose the data through a view in this database instead");
return false;
}
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
var authoredTable = parts[^1];
foreach (var part in parts)
{
if (IsRenderableIdentifier(part, dialect)) continue;
rejection = new SqlCatalogRejection(definition.Name, Field,
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
"containing control or Unicode format characters); the value is withheld from this message " +
"because it cannot be safely rendered");
return false;
}
if (!catalog.TryResolveSchema(authoredSchema, out schema))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
"case-sensitive collation)");
return false;
}
if (!catalog.TryResolveTable(schema, authoredTable, out table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
(authoredSchema is null
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
: string.Empty));
return false;
}
return true;
}
/// <summary>
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
/// embed in SQL and to render into a log line or an AdminUI label.
/// </summary>
/// <remarks>
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
/// discarded: only whether it threw is interesting here.
/// </remarks>
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
{
try
{
dialect.QuoteIdentifier(identifier);
return true;
}
catch (ArgumentException)
{
return false;
}
}
}
@@ -0,0 +1,189 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
/// dialect's catalog SQL (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
/// one <see cref="ISqlDialect.ListColumnsSql"/> per distinct authored relation. A driver polling three
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
/// confidently-empty address space.</para>
/// </remarks>
internal static class SqlCatalogLoader
{
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
/// <param name="dialect">Supplies the catalog SQL.</param>
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
/// <param name="commandTimeout">Per-command server-side backstop.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The loaded catalog.</returns>
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
internal static async Task<SqlCatalog> LoadAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyCollection<string> authoredTables,
TimeSpan commandTimeout,
CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
connection, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
// that basis would report an authoring fault for what is actually a grant problem, and would send the
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
if (schemas.Count == 0)
throw new InvalidOperationException(
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
"the catalog views, which is a grant problem rather than a tag-authoring one");
var defaultSchema = await ReadDefaultSchemaAsync(
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
// one table and validating against another would be a silent wrong-column defect.
foreach (var authored in authoredTables)
{
if (string.IsNullOrWhiteSpace(authored)) continue;
var parts = authored.Split('.');
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
var authoredTable = parts[^1];
string schema;
if (parts.Length == 2)
{
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
}
else
{
schema = defaultSchema;
}
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
connection, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, "@schema", schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
var key = SqlCatalog.QualifiedKey(schema, table);
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
connection, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
}
/// <summary>
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
/// 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.
/// </remarks>
private static async Task<string> ReadDefaultSchemaAsync(
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.DefaultSchemaSql;
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;
}
}
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
string sql,
string columnName,
Action<DbCommand> bind,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var results = new List<string>();
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
var ordinal = -1;
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal)) continue;
var value = reader.GetValue(ordinal)?.ToString();
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
}
}
}
return results;
}
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
}
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Resolves a <c>Sql</c> driver config's <c>connectionStringRef</c> — a NAME — into the connection string
/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never
/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff.
/// <para><b>The environment is read directly, on purpose.</b> A driver factory is invoked through
/// <c>DriverFactoryRegistry</c>'s <c>(driverInstanceId, driverConfigJson)</c> closure — there is no
/// <c>IConfiguration</c>, no <c>IServiceProvider</c> and no ambient scope at that seam, and widening the
/// registry's signature for one driver would change every driver in the tree. The
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c> spelling is exactly the key .NET's environment-variable
/// configuration provider would bind to <c>Sql:ConnectionStrings:&lt;ref&gt;</c>, so an operator can set it
/// the same way they set every other secret and a later move onto <c>IConfiguration</c> needs no
/// re-provisioning.</para>
/// <para><b>Nothing here ever emits the resolved value.</b> Messages name the environment
/// <em>variable</em>, which is not a secret and is the operator's next action; the value itself is returned
/// to exactly one caller and is otherwise unmentioned.</para>
/// </summary>
public static class SqlConnectionStringResolver
{
/// <summary>
/// The environment-variable prefix a <c>connectionStringRef</c> is appended to. The double underscore
/// is .NET's configuration hierarchy separator, so this is the environment spelling of
/// <c>Sql:ConnectionStrings:&lt;ref&gt;</c>.
/// </summary>
public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
/// <summary>The environment variable a given <c>connectionStringRef</c> is read from.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
public static string EnvironmentVariableFor(string connectionStringRef)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
}
/// <summary>
/// Resolves <paramref name="connectionStringRef"/> to its connection string.
/// <para>An absent <b>or blank</b> variable is a half-provisioned deployment and throws: handing an
/// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll,
/// far from the missing secret that actually caused it.</para>
/// </summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The resolved connection string. <b>Treat as a secret</b> — never log it.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">The environment variable is absent or blank.</exception>
public static string Resolve(string connectionStringRef)
{
var variable = EnvironmentVariableFor(connectionStringRef);
var value = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
$"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " +
$"variable '{variable}' on every node that runs this driver. The connection string is " +
$"deliberately not carried in the deployed configuration.");
}
return value;
}
}
@@ -0,0 +1,937 @@
using System.Data.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
/// <see cref="SqlPollReader"/> owns the read path.
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
/// new capability, not a flag flip.</para>
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
/// <see cref="IReadable"/> literally: it throws only when the database cannot be reached, and turns
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
/// <em>successfully</em>, so <see cref="PollGroupEngine"/> sees a clean tick, does not back off, and does
/// not call <see cref="HandlePollError"/>. Without <see cref="ObservePollOutcome"/> below, a driver whose
/// every value is <see cref="SqlStatusCodes.BadTimeout"/> would keep reporting
/// <see cref="DriverState.Healthy"/>. See that method for the exact rule and for why it does not
/// manufacture an exception to force backoff.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
{
/// <summary>
/// The driver-type string this driver registers under — <see cref="DriverTypeNames.Sql"/>, the single
/// source of truth the deploy pipeline stores in <c>DriverInstance.DriverType</c> and every dispatch
/// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so
/// <c>DriverTypeNamesGuardTests</c>' bidirectional-parity check is satisfied).
/// </summary>
public const string DriverTypeName = DriverTypeNames.Sql;
/// <summary>Shown for a connection string that names no recognisable server.</summary>
private const string UnknownEndpoint = "(unknown sql endpoint)";
/// <summary>Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>Connection-string keys that name the server, in the order they are consulted.</summary>
private static readonly string[] ServerKeys =
["server", "data source", "datasource", "host", "address", "addr", "network address"];
/// <summary>Connection-string keys that name the database.</summary>
private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
// ---- instance fields (grouped at top for auditability) ----
private readonly SqlDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ISqlDialect _dialect;
private readonly DbProviderFactory _factory;
/// <summary>
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
/// </summary>
private readonly string _connectionString;
private readonly ILogger<SqlDriver> _logger;
/// <summary>
/// The authored RawPath → definition table, held as an <b>immutable snapshot swapped atomically</b>
/// rather than as a mutated dictionary (the shape <c>ModbusDriver</c> uses).
/// <see cref="ReinitializeAsync"/> rebuilds this table while poll loops are reading it; clearing and
/// refilling a shared <see cref="Dictionary{TKey,TValue}"/> under that concurrency is a torn read at
/// best and an <see cref="InvalidOperationException"/> inside a poll at worst.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
/// reach a query.
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
/// node: §8.1's specified outcome is that it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, and a status code can only be published by a node
/// that is there. Dropping rejected tags from the authored table too would delete the node instead,
/// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse
/// failure for the operator trying to find their typo.</para>
/// <para>Swapped atomically, for the same reason the authored table is.</para>
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary>
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
/// delegate must read this driver's live authored table, which does not exist before the driver does.
/// </summary>
private readonly SqlPollReader _reader;
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
private readonly PollGroupEngine _poll;
// Single logical host: one driver instance dials one connection string. HostName is the endpoint
// description (server[/database]) so the Admin UI shows operators where to look.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- ctor + identity ----
/// <summary>Initializes a new <c>Sql</c> driver instance.</summary>
/// <param name="options">The driver's typed configuration (the factory applies the documented defaults).</param>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="dialect">
/// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
/// </param>
/// <param name="connectionString">
/// The <b>already-resolved</b> connection string. The driver never resolves a
/// <c>connectionStringRef</c> itself and never logs this value.
/// </param>
/// <param name="factory">
/// Creates the provider's connections. Defaults to <paramref name="dialect"/>'s factory; passed
/// explicitly by tests that need to observe or delay connection creation.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
public SqlDriver(
SqlDriverOptions options,
string driverInstanceId,
ISqlDialect dialect,
string connectionString,
DbProviderFactory? factory = null,
ILogger<SqlDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(dialect);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
_options = options;
_driverInstanceId = driverInstanceId;
_dialect = dialect;
_factory = factory ?? dialect.Factory;
_connectionString = connectionString;
_logger = logger ?? NullLogger<SqlDriver>.Instance;
Endpoint = DescribeEndpoint(connectionString);
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
_reader = new SqlPollReader(
_factory,
connectionString,
dialect,
commandTimeout: options.CommandTimeout,
operationTimeout: options.OperationTimeout,
maxConcurrentGroups: options.MaxConcurrentGroups,
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
logger: _logger);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => DriverTypeName;
/// <summary>
/// The credential-free description of the database this instance polls — <c>server/database</c> where
/// the connection string names both. This is the only rendering of the connection string that may
/// appear in a log, a health message, or the Admin UI.
/// </summary>
public string Endpoint { get; }
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
public string HostName => Endpoint;
/// <summary>Active polled-subscription count. Diagnostics + disposal assertions.</summary>
internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
/// <summary>
/// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
/// <summary>
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
/// rejected must miss here so the reader publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
/// </summary>
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
// ---- IDriver lifecycle ----
/// <summary>
/// Builds the authored RawPath table and proves the database is reachable.
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
/// (<see cref="ApplyCatalogGateAsync"/>), which is the second and last piece of I/O. It runs after
/// liveness because it needs a working connection, and before the driver reports Healthy because the
/// tag table it publishes must be the validated one — a poll must never see an unvalidated
/// identifier.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
BuildTagTable();
try
{
await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller tore the initialization down; not a database verdict.
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
throw;
}
catch (Exception ex)
{
// Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is
// dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's
// message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an
// unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only
// the exception TYPE reaches LastError/host status; the full exception object goes to the log
// SINK via the structured logger's exception parameter (below), never into a rendered string.
var message =
$"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " +
$"Check the database is reachable and the connection string is valid.";
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
// Separate try from liveness so the operator surface names the stage that actually failed: "could
// not reach the database" and "reached it but could not read its catalog" send an operator to
// different places, and the second is usually a GRANT rather than an outage.
try
{
await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
throw;
}
catch (Exception ex)
{
// Same discipline as above: exception TYPE only on the operator surface, full exception to the
// log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of
// evidence — so this faults the driver (and retries) rather than rejecting every tag and serving
// a confidently-empty address space.
var message =
$"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " +
$"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " +
"can read the catalog views.";
_logger.LogError(ex,
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
_logger.LogInformation(
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
_driverInstanceId, Endpoint, Tags.Count);
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
/// <returns>Always zero.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <summary>
/// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
/// could only produce the same nodes.
/// </summary>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>
/// False — <see cref="DiscoverAsync"/> replays authored tags rather than enumerating the backend, so
/// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
/// a separate surface: <c>Driver.Sql.Browser</c>.)
/// </summary>
public bool SupportsOnlineDiscovery => false;
/// <summary>
/// Streams the authored tags as read-only variables.
/// <para>An undeclared tag materialises as <see cref="DriverDataType.String"/> — the same fallback
/// <see cref="ISqlDialect.MapColumnType"/> uses for an unrecognised column type — because a column's
/// real type is only known once a poll has returned result-set metadata, which has not happened at
/// discovery time. Author <c>"type"</c> on a numeric tag.</para>
/// </summary>
/// <param name="builder">The address-space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task — discovery is synchronous over the authored table.</returns>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder(DriverTypeName, DriverTypeName);
foreach (var tag in Tags.Values)
{
if (tag.DeclaredType is null)
{
// Discovery cannot infer a column's real type — that needs live result-set metadata, which
// only a poll returns — so an omitted-type tag materialises as String (the same fallback
// ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual,
// possibly numeric, CLR value against that String node. Nothing else signals the operator to
// this declared-vs-published mismatch, so warn and point them at the fix.
_logger.LogWarning(
"Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " +
"will then publish numeric values against a String node. Author an explicit \"type\" on " +
"numeric tags. Driver={DriverInstanceId}",
tag.Name, _driverInstanceId);
}
folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
IsArray: false,
ArrayDim: null,
// v1 is read-only: no tag, and no configuration, can widen this.
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
return Task.CompletedTask;
}
// ---- IReadable ----
/// <summary>
/// Reads a batch, delegating to <see cref="SqlPollReader"/> and classifying what came back
/// (<see cref="ObservePollOutcome"/>).
/// </summary>
/// <param name="fullReferences">The RawPaths to read.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="DbException">The database could not be reached — the engine backs off on this.</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
// unreachable" and degrade a perfectly healthy driver.
ArgumentNullException.ThrowIfNull(fullReferences);
try
{
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
ObservePollOutcome(snapshots);
return snapshots;
}
catch (OperationCanceledException)
{
// Teardown, not a database verdict — leave health and host state exactly as they were.
throw;
}
catch (Exception ex)
{
// The reader throws for one reason only: opening a connection failed (IReadable's contract).
_logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
_driverInstanceId, Endpoint);
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
// on the log sink via the LogWarning exception parameter above.
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
throw;
}
}
// ---- ISubscribable (polling overlay via the shared engine) ----
/// <summary>
/// Registers a polled subscription.
/// <para>A non-positive <paramref name="publishingInterval"/> falls back to the configured
/// <see cref="SqlDriverOptions.DefaultPollInterval"/>; anything faster than
/// <see cref="PollGroupEngine.DefaultMinInterval"/> is floored by the engine.</para>
/// </summary>
/// <param name="fullReferences">The RawPaths to poll.</param>
/// <param name="publishingInterval">The requested publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The subscription handle.</returns>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
return Task.FromResult(_poll.Subscribe(fullReferences, interval));
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// The single logical host this instance dials. There is no background probe loop: the state is a
/// by-product of the Initialize-time liveness check and of every poll, so the report is always a
/// statement about traffic that actually happened rather than about a synthetic ping.
/// </summary>
/// <returns>A one-element list describing the configured database endpoint.</returns>
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
// ---- health + host-state classification ----
/// <summary>
/// Classifies one poll's snapshots into a health verdict and a host state.
/// <list type="bullet">
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
/// host is Running.</item>
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
/// the host is Stopped.</item>
/// <item>Bad codes that are <b>authoring</b> facts — an unresolvable RawPath, an absent row, a
/// type mismatch, a rejected definition — change nothing. A tag typo must never report the
/// database as down and send an operator to the wrong system.</item>
/// </list>
/// <para><b>Why this does not throw to force poll-engine backoff.</b> Backoff would require turning
/// an all-<c>BadTimeout</c> batch into an exception, and the engine's exception path publishes
/// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
/// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
/// already bounded by <c>operationTimeout</c> and the reader never holds more than
/// <c>maxConcurrentGroups</c> connections however long the database stays frozen. So a frozen
/// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
/// cadence rather than backed off from.</para>
/// </summary>
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
{
if (snapshots.Count == 0) return;
var good = 0;
var unreachable = 0;
foreach (var snapshot in snapshots)
{
if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
}
if (unreachable > 0)
{
Degrade(
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
$"{Endpoint} on the last poll.");
if (good > 0)
{
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
TouchLastSuccessfulRead();
TransitionHostTo(HostState.Running);
}
else
{
TransitionHostTo(HostState.Stopped);
}
return;
}
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
// to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
}
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
private static bool IsConnectionClass(uint statusCode)
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
/// <summary>
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
/// touch host state: the engine also reports its own contract violations here, which say nothing
/// about connectivity — the unreachable-database transition is raised by <see cref="ReadAsync"/>,
/// which knows the exception came from the reader.
/// </summary>
/// <param name="ex">The exception the poll engine caught.</param>
internal void HandlePollError(Exception ex)
{
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
// guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
if (ex is DbException) return;
// Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
// (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
// credential fragments) — the operator surface carries only the exception TYPE, never ex.Message.
// The full exception, with its text, still reaches the log SINK via the exception parameter. This is
// the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check.
_logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
_driverInstanceId, Endpoint);
Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}.");
}
/// <summary>
/// Degrades health, preserving <c>LastSuccessfulRead</c> and never downgrading
/// <see cref="DriverState.Faulted"/> — a config-level verdict that only a successful read or an
/// operator reinitialize may clear.
/// </summary>
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
private void Degrade(string reason)
{
var current = ReadHealth();
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
}
/// <summary>
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
/// <see cref="ReinitializeAsync"/> just faulted.
/// </summary>
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
private void SetHealthUnlessFaulted(DriverHealth value)
{
if (ReadHealth().State == DriverState.Faulted) return;
WriteHealth(value);
}
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
private void TouchLastSuccessfulRead()
{
var current = ReadHealth();
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
}
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
private void TransitionHostTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
// ---- authored tag table ----
/// <summary>
/// Maps every authored <see cref="RawTagEntry"/> through <see cref="SqlEquipmentTagParser.TryParse"/>
/// and publishes the result as one atomic snapshot. A blob that does not map is <b>logged and
/// skipped</b>, never thrown: one malformed tag must not take the whole driver — and therefore every
/// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
/// publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> rather than someone else's row.
/// </summary>
private void BuildTagTable()
{
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
foreach (var entry in _options.RawTags)
{
try
{
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
table[entry.RawPath] = definition;
else
_logger.LogWarning(
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
catch (Exception ex)
{
// TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's
// try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
// branch above follows — rather than fault the driver over a single entry.
_logger.LogError(
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
Volatile.Write(ref _tagsByRawPath, table);
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
// previous generation's validated table in place) means a Reinitialize whose gate then fails cannot
// keep polling the OLD identifiers against a database whose schema may be exactly what changed.
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
}
// ---- catalog gate (design §8.1) ----
/// <summary>
/// Validates every authored identifier against the live catalog and republishes the tag table with
/// catalog spellings, dropping the tags that do not resolve (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Why this must run before the driver reports Healthy.</b> The table this swaps in is what
/// every poll resolves against. Running it later — or in the background — would leave a window in
/// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate
/// exists to close.</para>
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
/// typo must not stop the other tags on that database, the same rule
/// <see cref="BuildTagTable"/> follows for a malformed blob. Each drop is logged at Warning with the
/// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is
/// unsupportable.</para>
/// <para><b>No tags ⇒ no I/O.</b> A driver with nothing authored has nothing to validate, and issuing
/// catalog queries to prove that would be a round-trip that can only fail.</para>
/// <para>Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work
/// runs on the thread pool so a provider implementing the async path synchronously blocks a pool
/// thread rather than wedging Initialize forever with no retry.</para>
/// </remarks>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken)
{
var authored = Tags;
if (authored.Count == 0) return;
var tables = authored.Values
.Select(definition => definition.Table)
.Where(table => !string.IsNullOrWhiteSpace(table))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = await RunBoundedAsync(
token => LoadCatalogAsync(tables, token),
_options.OperationTimeout,
"the catalog queries did not return",
cancellationToken).ConfigureAwait(false);
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
foreach (var rejection in result.Rejected)
{
_logger.LogWarning(
"Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish "
+ "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}",
rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId);
}
var validated = new Dictionary<string, SqlTagDefinition>(result.Accepted.Count, StringComparer.Ordinal);
foreach (var definition in result.Accepted) validated[definition.Name] = definition;
// Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps
// its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath).
Volatile.Write(ref _polledByRawPath, validated);
_logger.LogInformation(
"Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) "
+ "across {Tables} table(s) on {Endpoint}.",
_driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint);
}
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
private async Task<SqlCatalog> LoadCatalogAsync(
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return await SqlCatalogLoader
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
.ConfigureAwait(false);
}
}
// ---- liveness ----
/// <summary>
/// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
/// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
/// long-lived connection here would only be a second thing to keep alive.
/// <para><b>Bounded by wall-clock, not only by the token</b> (the R2-01 / STAB-14 lesson): some ADO.NET
/// providers implement the async path synchronously, and a wedged socket can hang inside the
/// provider's own cancellation handshake. If Initialize hung there, <c>DriverInstanceActor</c>'s init
/// task would never complete and the driver would sit in Connecting forever with no retry. The work
/// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
/// an abandoned attempt still owns and disposes its own connection.</para>
/// </summary>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the database has answered.</returns>
private Task VerifyLivenessAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(
async token => { await PingAsync(token).ConfigureAwait(false); return true; },
_options.CommandTimeout,
"the liveness statement did not return",
cancellationToken);
/// <summary>
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
/// </summary>
/// <remarks>
/// <para>The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists
/// because a token is not a deadline: some ADO.NET providers implement the async path synchronously,
/// and a wedged socket can hang <em>inside</em> the provider's own cancellation handshake. Without an
/// independent wall clock, <c>DriverInstanceActor</c>'s init task would never complete and the driver
/// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped.</para>
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
/// </remarks>
/// <typeparam name="T">The work's result type.</typeparam>
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
/// <param name="budget">The wall-clock allowance.</param>
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>The work's result.</returns>
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
private static async Task<T> RunBoundedAsync<T>(
Func<CancellationToken, Task<T>> work,
TimeSpan budget,
string what,
CancellationToken cancellationToken)
{
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<T> running;
try
{
deadline.CancelAfter(budget);
running = Task.Run(
async () =>
{
try { return await work(deadline.Token).ConfigureAwait(false); }
finally { deadline.Dispose(); }
},
CancellationToken.None);
}
catch
{
// Ownership of the CTS transfers to the work task; release it only if that task never started.
deadline.Dispose();
throw;
}
try
{
return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
Detach(running);
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Our deadline fired and the provider DID honour the linked token.
Detach(running);
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
}
}
/// <summary>Opens a connection and executes the dialect's cheapest proof-of-life statement.</summary>
private async Task PingAsync(CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = _dialect.LivenessSql;
// ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
/// task exception. The task still owns — and disposes — its own connection.
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
// ---- endpoint description ----
/// <summary>
/// Renders a connection string as <c>server/database</c>, reading only those two keys.
/// <para>This is the <b>only</b> function permitted to look at the connection string outside the
/// provider call, and it exists so that no other code is ever tempted to log the string itself:
/// credentials live in <c>User ID</c> / <c>Password</c> / <c>Authentication</c>, which are never
/// read here. A string that cannot be parsed yields <see cref="UnknownEndpoint"/> — an unusable
/// description must not become a crash at construction.</para>
/// </summary>
/// <param name="connectionString">The resolved connection string.</param>
/// <returns>A credential-free description safe to log and display.</returns>
private static string DescribeEndpoint(string connectionString)
{
try
{
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
var server = FirstValue(builder, ServerKeys);
if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
var database = FirstValue(builder, DatabaseKeys);
return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
}
catch (ArgumentException)
{
return UnknownEndpoint;
}
}
/// <summary>Reads the first of <paramref name="keys"/> the builder carries; null when it carries none.</summary>
private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
{
foreach (var key in keys)
{
// DbConnectionStringBuilder's key comparison is case-insensitive.
if (builder.TryGetValue(key, out var value) && value is not null)
{
var text = value.ToString();
if (!string.IsNullOrWhiteSpace(text)) return text;
}
}
return null;
}
// ---- teardown ----
/// <summary>
/// Performs the same teardown as <see cref="ShutdownAsync"/>, so a caller that only uses
/// <c>await using</c> does not leak the poll loops.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
/// <summary>
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
/// to call any number of times, in any order, which is what makes
/// <c>ShutdownAsync</c>-then-<c>DisposeAsync</c> (and <see cref="ReinitializeAsync"/>) safe.
/// <para>There is no connection to close: the reader opens and disposes one per poll.</para>
/// </summary>
private async Task TeardownAsync()
{
await _poll.DisposeAsync().ConfigureAwait(false);
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
}
}
@@ -0,0 +1,252 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
/// <c>operationTimeout</c>/<c>commandTimeout</c> pair on purpose (the frozen-database tests need that
/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
/// its two independent deadlines. Every knob is checked here, once, at construction.</para>
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
/// rendering — never the string itself.</para>
/// </summary>
public static class SqlDriverFactoryExtensions
{
/// <summary>
/// The driver-type string this factory registers under — chained off
/// <see cref="SqlDriver.DriverTypeName"/>, which is
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
/// </summary>
public const string DriverTypeName = SqlDriver.DriverTypeName;
/// <summary>
/// How a <c>Sql</c> config blob is read.
/// <list type="bullet">
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
/// ordinal, and always writes the name (the enum-serialization guard).</item>
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
/// schema version must not brick the driver.</item>
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
/// what makes a <em>write</em> round-trip to the authored shape.</item>
/// </list>
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
/// <summary>
/// Registers the <c>Sql</c> factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
/// driver instance; without it the driver runs with the null logger.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
/// exist until the driver does.</para>
/// <para>No I/O happens here. The database is first contacted by
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
/// Reconnecting rather than a deployment that cannot be constructed.</para>
/// </summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
/// </exception>
public static SqlDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
// secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
var options = BuildOptions(dto, driverInstanceId);
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
if (dto.AllowWrites is true)
{
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
// on, however, can — so this is loud.
logger.LogWarning(
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
"driver is read-only (it implements no write capability), so no configuration can enable a " +
"write. Remove the flag, or expect reads only.",
driverInstanceId);
}
var driver = new SqlDriver(
options,
driverInstanceId,
dialect,
connectionString,
factory: null,
logger: loggerFactory?.CreateLogger<SqlDriver>());
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
// permitted outside the provider call.
logger.LogInformation(
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
options.DefaultPollInterval, options.OperationTimeout);
return driver;
}
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' deserialised to null");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
}
}
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
/// </summary>
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
=> provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
_ => throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
};
/// <summary>
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
/// <para>Internal so the defaults and the rejections can be asserted directly, without a database or a
/// provisioned connection string standing between the test and the rule.</para>
/// </summary>
/// <param name="dto">The deserialised config blob.</param>
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
/// <returns>The typed options the driver is constructed with.</returns>
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
{
ArgumentNullException.ThrowIfNull(dto);
var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
if (maxConcurrentGroups < 1)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
$"it must be at least 1.");
}
// Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
// server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
// wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
// first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
// two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
if (operationTimeout <= commandTimeout)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
$"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
$"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
$"never fire.");
}
return new SqlDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
DefaultPollInterval = defaultPollInterval,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
MaxConcurrentGroups = maxConcurrentGroups,
NullIsBad = dto.NullIsBad ?? false,
};
}
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
{
if (value <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
}
}
}
@@ -0,0 +1,56 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The typed form of a <c>Sql</c> driver instance's configuration — what
/// <see cref="SqlDriverConfigDto"/> becomes once the factory has applied its defaults (design §5.1).
/// Mirrors <c>ModbusDriverOptions</c>: the driver instance is constructed with these, and its
/// <see cref="SqlDriver.InitializeAsync"/> serves them rather than re-parsing the config JSON.
/// <para><b>No connection string here.</b> The resolved connection string is a separate constructor
/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.</para>
/// </summary>
public sealed class SqlDriverOptions
{
/// <summary>
/// The authored raw tags this driver serves. The deploy artifact hands each authored raw <c>Tag</c>
/// as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob); the driver maps
/// each through <see cref="SqlEquipmentTagParser.TryParse"/> into its RawPath → definition table.
/// A SQL source has no tag-discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Poll interval used when a subscriber does not ask for one (design §4: <c>defaultPollInterval</c>,
/// default 5 s). A subscriber that names an interval gets that interval, floored by
/// <see cref="PollGroupEngine.DefaultMinInterval"/>.
/// </summary>
public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
/// publishes <see cref="SqlStatusCodes.BadTimeout"/> for that group's tags.
/// </summary>
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
/// <summary>
/// The ADO.NET <c>CommandTimeout</c> server-side backstop (default 10 s), also the bound on the
/// Initialize-time liveness check.
/// <para>Authoring must keep <see cref="OperationTimeout"/> strictly greater than this (design §8.3);
/// inverted, the client-side abort always fires first and masks the backstop. <b>That rule is
/// enforced by config validation in the factory, not here</b> — the reader deliberately stays usable
/// with the order inverted so the frozen-database tests can prove the client-side bound fires.</para>
/// </summary>
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>Cap on concurrently executing group queries — and therefore on concurrently open connections.</summary>
public int MaxConcurrentGroups { get; init; } = 4;
/// <summary>
/// When <see langword="true"/>, a present row whose value cell is NULL publishes
/// <see cref="SqlStatusCodes.Bad"/> instead of the default <see cref="SqlStatusCodes.Uncertain"/>.
/// It never governs an absent row, which is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </summary>
public bool NullIsBad { get; init; }
}
@@ -0,0 +1,171 @@
using System.Data.Common;
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
/// <para><b>Parses the SAME factory DTO with the SAME converter as
/// <see cref="SqlDriverFactoryExtensions"/></b> (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
/// <c>provider</c> parses here exactly as it does at the factory, and the same
/// <c>connectionStringRef</c> resolves the same way.</para>
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
/// catch.</para>
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
/// </summary>
public sealed class SqlDriverProbe : IDriverProbe
{
/// <summary>
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
/// probe can run against the SQLite fixture with no SQL Server and no network.
/// </summary>
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
/// <summary>
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
/// catalog SQL. Null in production: the dialect's own factory is used.
/// </summary>
private readonly DbProviderFactory? _factoryOverride;
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
public SqlDriverProbe()
: this(DefaultDialectFor, factoryOverride: null)
{
}
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
{
_dialectFor = dialectFor;
_factoryOverride = factoryOverride;
}
/// <summary>
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
/// runs unmodified.
/// </summary>
/// <param name="factory">The provider factory the probe opens connections from.</param>
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
/// <returns>A probe wired to the supplied factory + dialect.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
return new SqlDriverProbe(_ => dialect, factory);
}
/// <inheritdoc />
public string DriverType => SqlDriver.DriverTypeName;
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
SqlDriverConfigDto? dto;
try
{
dto = System.Text.Json.JsonSerializer.Deserialize<SqlDriverConfigDto>(
configJson, SqlDriverFactoryExtensions.JsonOptions);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
{
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);
ISqlDialect dialect;
string connectionString;
try
{
dialect = _dialectFor(dto.Provider);
connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
}
catch (InvalidOperationException ex)
{
// Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
// safe and actionable (it names the environment variable the operator must set).
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
var factory = _factoryOverride ?? dialect.Factory;
return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
}
/// <summary>
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
/// data source, so its <c>.Message</c> is deliberately not surfaced).
/// </summary>
private static async Task<DriverProbeResult> RunLivenessAsync(
DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
CancellationToken ct)
{
var stopwatch = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);
try
{
var connection = factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = connectionString;
await connection.OpenAsync(cts.Token).ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
}
stopwatch.Stop();
return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return new DriverProbeResult(false, "Probe cancelled.", null);
}
catch (OperationCanceledException)
{
return new DriverProbeResult(
false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
// The provider's own message can carry the data source (host/database), which is not a secret but
// is more than the operator needs — and keeping the message to a fixed class is what guarantees no
// credential-bearing connection string can ever slip through. Name the exception TYPE only.
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
}
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
_ => throw new InvalidOperationException(
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
};
}
@@ -0,0 +1,250 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Folds a set of <see cref="SqlTagDefinition"/>s into the minimum number of parameterized queries
/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> when every
/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
/// <c>SqlGroupPlannerTests</c>.</para>
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
/// interpolation of tag content into SQL anywhere in this type.</para>
/// </summary>
public static class SqlGroupPlanner
{
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
private const string KeyMarkerPrefix = "@k";
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
private const string RowSelectorMarker = "@w";
/// <summary>
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
/// <para>Deterministic: groups appear in the input order of their first member, members keep their input
/// order within a group, and parameters keep the first-appearance order of their distinct values — so a
/// given tag list always yields byte-identical SQL.</para>
/// </summary>
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
/// <returns>One plan per distinct group key.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
/// <exception cref="ArgumentException">
/// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
/// identifier cannot be quoted (an empty table-name part, a control character — see
/// <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
/// </exception>
/// <exception cref="NotSupportedException">
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
/// <b>Deliberately loud rather than skipped:</b> silently dropping a tag would leave an authored node
/// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
/// </exception>
public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(dialect);
// Ordered grouping: the dictionary decides membership, the list decides emission order.
var order = new List<object>();
var groups = new Dictionary<object, List<SqlTagDefinition>>();
foreach (var tag in tags)
{
ArgumentNullException.ThrowIfNull(tag);
var key = BuildGroupKey(tag);
if (!groups.TryGetValue(key, out var members))
{
members = [];
groups.Add(key, members);
order.Add(key);
}
members.Add(tag);
}
var plans = new List<SqlQueryPlan>(order.Count);
foreach (var key in order)
{
var members = groups[key];
plans.Add(members[0].Model switch
{
SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
_ => throw UnsupportedModel(members[0]),
});
}
return plans;
}
/// <summary>
/// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose
/// first element is the model, so a key-value key can never compare equal to a wide-row key.
/// <list type="bullet">
/// <item>
/// <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
/// Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
/// would make one tag publish the other's column.
/// </item>
/// <item>
/// <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
/// — i.e. the table plus the whole row selector, exactly design §5.3's
/// <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
/// deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
/// one row), and they are added to the SELECT list instead.
/// </item>
/// </list>
/// <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
/// two tags authored <c>dbo.T</c> and <c>DBO.T</c> plan as two groups. That costs one extra round-trip
/// and is never wrong; guessing the server's collation here could fold two genuinely different sources.</para>
/// </summary>
private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch
{
SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn),
SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue,
tag.RowSelectorTopByTimestamp),
_ => throw UnsupportedModel(tag),
};
/// <summary>
/// Emits <c>SELECT &lt;key&gt;, &lt;value&gt;[, &lt;ts&gt;] FROM &lt;table&gt; WHERE &lt;key&gt; IN (@k0..@kN)</c>.
/// The key column is selected because the reader indexes rows by it to slice values back per member.
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
/// </summary>
private static SqlQueryPlan BuildKeyValuePlan(
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
{
var first = members[0];
var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first);
var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first);
var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn;
var selected = new List<string>(3);
AddDistinct(selected, keyColumn);
AddDistinct(selected, valueColumn);
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
var names = new List<string>(members.Count);
var values = new List<object?>(members.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
{
var keyValue = member.KeyValue
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
if (!seen.Add(keyValue)) continue;
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
values.Add(keyValue);
}
var sql = new StringBuilder("SELECT ")
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", names).Append(')')
.ToString();
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
}
/// <summary>
/// Emits <c>SELECT &lt;cols&gt; FROM &lt;table&gt; WHERE &lt;whereColumn&gt; = @w</c>, or — for a
/// <c>topByTimestamp</c> selector — the single-row form
/// <c>SELECT &lt;limit&gt; &lt;cols&gt; FROM &lt;table&gt; ORDER BY &lt;ts&gt; DESC &lt;limit&gt;</c>, whose
/// row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
/// <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
/// row in the result already matches the bound value, so reading it back adds nothing.</para>
/// </summary>
private static SqlQueryPlan BuildWideRowPlan(
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
{
var first = members[0];
var selected = new List<string>(members.Count + 1);
foreach (var member in members)
AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member));
foreach (var member in members)
if (!Blank(member.TimestampColumn))
AddDistinct(selected, member.TimestampColumn!);
var table = QuoteQualifiedName(first.Table, dialect);
var columns = QuoteList(selected, dialect);
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
var sql = string.Concat(
"SELECT ", columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
}
if (!Blank(first.RowSelectorTopByTimestamp))
{
// The row limit is dialect syntax and sits at opposite ends of the statement per provider
// (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted
// unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix.
var sql = string.Concat(
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
dialect.SingleRowLimitSuffix);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
}
throw new ArgumentException(
$"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" +
"whereValue pair or a rowSelector.topByTimestamp column.", nameof(members));
}
/// <summary>
/// Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
/// <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
/// <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
/// form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
/// empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
/// whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
/// </summary>
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
{
if (Blank(name))
throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name));
return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
}
/// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
=> string.Join(", ", columns.Select(dialect.QuoteIdentifier));
/// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
private static void AddDistinct(List<string> target, string value)
{
if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
}
/// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
/// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag)
=> Blank(value) ? throw MissingField(field, tag) : value!;
private static ArgumentException MissingField(string field, SqlTagDefinition tag)
=> new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'.");
private static NotSupportedException UnsupportedModel(SqlTagDefinition tag)
=> new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " +
"v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4).");
}
@@ -0,0 +1,839 @@
using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Executes one poll pass: resolves each requested reference to a <see cref="SqlTagDefinition"/>, folds
/// the definitions into the minimum number of queries via <see cref="SqlGroupPlanner"/>, runs one
/// command per group under a bounded deadline, and slices each result set back to per-tag
/// <see cref="DataValueSnapshot"/>s — <b>positionally aligned with the caller's reference list</b>
/// (design §3.2). Structurally the SQL analogue of <c>ModbusDriver.ReadCoalescedAsync</c>: group → one
/// round-trip → slice back.
/// <para><b>N in, N out, in order.</b> The returned list always has exactly one entry per requested
/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never
/// exceptions (the <see cref="IReadable"/> contract) — see <see cref="SqlStatusCodes"/> for which code
/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags,
/// so a slicing defect does not fail loudly — it publishes <em>one tag's value onto another tag's
/// node</em>.</para>
/// <para><b>The whole call throws only when the database is unreachable</b> — that is, when opening a
/// connection fails. That surfaces to <see cref="PollGroupEngine"/> as a poll failure and earns the
/// capped-exponential backoff; the driver's <c>IHostConnectivityProbe</c> is what scopes Bad quality
/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for
/// an outage. A query that fails <em>after</em> the connection opened is the group's problem, not the
/// server's, and Bad-codes that group only.</para>
/// <para><b>Deadlines (design §8.3, the R2-01 frozen-peer lesson).</b> Two independent mechanisms, both
/// required. <c>CommandTimeout</c> is the <em>server-side</em> backstop and bounds nothing on a wedged
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
/// <see cref="RunGroupAsync"/>. A frozen database yields <see cref="SqlStatusCodes.BadTimeout"/>
/// snapshots — it must never wedge the poll thread.</para>
/// <para><b>Not a driver.</b> This type owns the read path only; it deliberately holds no health state,
/// no subscription state and no connection between polls, so the driver shell can wrap it in
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
/// </summary>
public sealed class SqlPollReader
{
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
private readonly DbProviderFactory _factory;
private readonly string _connectionString;
private readonly ISqlDialect _dialect;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly bool _nullIsBad;
private readonly Func<string, SqlTagDefinition?> _resolve;
private readonly ILogger _logger;
/// <summary>
/// Caps concurrently executing group queries — and therefore concurrently held connections
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
/// timed-out-but-still-running group releasing into a disposed semaphore.
/// </summary>
private readonly SemaphoreSlim _gate;
private long _lastContractWarningTicks;
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
/// <param name="factory">
/// Creates the provider's connections. Passed explicitly rather than taken from
/// <paramref name="dialect"/> so the driver can hand in an instrumented or pre-configured factory.
/// </param>
/// <param name="connectionString">
/// The resolved connection string (never the authored <c>connectionStringRef</c> — secrets are
/// resolved by the driver at Initialize, design §8.2).
/// </param>
/// <param name="dialect">Supplies identifier quoting, row-limit syntax, and column-type mapping.</param>
/// <param name="commandTimeout">
/// The ADO.NET <c>CommandTimeout</c> backstop. Rounded <b>up</b> to whole seconds and floored at 1,
/// because ADO.NET reads <c>CommandTimeout = 0</c> as "wait forever" — the one value this option
/// must never silently become.
/// </param>
/// <param name="operationTimeout">
/// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening
/// the connection, and running the query. A breach Bad-codes that group with
/// <see cref="SqlStatusCodes.BadTimeout"/>.
/// <para>Authoring should keep this strictly greater than <paramref name="commandTimeout"/>
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
/// backstop. That rule is <b>not</b> enforced here — it belongs to config validation, and this type
/// stays usable for the tests that must deliberately invert it.</para>
/// </param>
/// <param name="maxConcurrentGroups">
/// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot
/// until it truly finishes, so this is a hard ceiling on connections even against a frozen database.
/// See <see cref="RunGroupAsync"/> for why a high value is safe under
/// <c>Microsoft.Data.SqlClient</c> but wants sizing against thread-pool headroom under a provider
/// that blocks synchronously.
/// </param>
/// <param name="nullIsBad">
/// <see langword="true"/> publishes <see cref="SqlStatusCodes.Bad"/> for a NULL value cell instead
/// of the default <see cref="SqlStatusCodes.Uncertain"/>. It governs a <em>present</em> row with a
/// NULL cell only — an absent row is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </param>
/// <param name="resolve">
/// RawPath → tag definition; <see langword="null"/> on a miss. Shaped to match
/// <see cref="EquipmentTagRefResolver{TDef}"/>'s lookup so the driver can pass its authored table
/// straight through.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is blank.</exception>
/// <exception cref="ArgumentOutOfRangeException">A timeout is non-positive, or the cap is below 1.</exception>
public SqlPollReader(
DbProviderFactory factory,
string connectionString,
ISqlDialect dialect,
TimeSpan commandTimeout,
TimeSpan operationTimeout,
int maxConcurrentGroups,
bool nullIsBad,
Func<string, SqlTagDefinition?> resolve,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
ArgumentNullException.ThrowIfNull(resolve);
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString));
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1);
_factory = factory;
_connectionString = connectionString;
_dialect = dialect;
_operationTimeout = operationTimeout;
_commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
_nullIsBad = nullIsBad;
_resolve = resolve;
_logger = logger ?? NullLogger.Instance;
_gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups);
}
/// <summary>
/// Reads every reference in one poll pass. Matches <see cref="IReadable.ReadAsync"/>'s shape so the
/// driver shell can delegate to it directly.
/// </summary>
/// <param name="fullReferences">The RawPaths to read. Empty yields an empty result and touches no connection.</param>
/// <param name="cancellationToken">
/// The caller's token. Its cancellation <b>propagates</b> as an
/// <see cref="OperationCanceledException"/> — the engine asked the poll to stop, and nobody is
/// waiting for the snapshots. This is deliberately asymmetric with an <c>operationTimeout</c>
/// breach, which is a data-quality fact clients must see and so becomes
/// <see cref="SqlStatusCodes.BadTimeout"/> snapshots.
/// </param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fullReferences"/> is null.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
if (results.Length == 0) return results;
var readAt = DateTime.UtcNow;
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
// whose members are the sole thing the slice-back walks.
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
var slots = new Dictionary<SqlTagDefinition, List<int>>();
for (var i = 0; i < fullReferences.Count; i++)
{
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
if (definition is null)
{
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
continue;
}
definitions.Add(definition);
if (!slots.TryGetValue(definition, out var positions))
slots[definition] = positions = [];
positions.Add(i);
}
if (definitions.Count > 0)
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
for (var i = 0; i < results.Length; i++)
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
return results;
}
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
private async Task ReadGroupsAsync(
List<SqlTagDefinition> definitions,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
IReadOnlyList<SqlQueryPlan> plans;
try
{
plans = SqlGroupPlanner.Plan(definitions, _dialect);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
// reaching here means a definition was built past the parser. It is an authoring fault, not a
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
definitions.Count);
foreach (var definition in definitions)
{
if (!slots.TryGetValue(definition, out var positions)) continue;
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
return;
}
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
// carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by
// plans.Count × operationTimeout.
var groups = new Task<GroupResult>[plans.Count];
for (var g = 0; g < plans.Count; g++)
groups[g] = RunGroupAsync(plans[g], cancellationToken);
// WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never
// leaves a sibling group running against a connection nobody is watching.
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
for (var g = 0; g < plans.Count; g++)
Slice(plans[g], outcomes[g], slots, results, readAt);
}
/// <summary>
/// Runs one group's query under a hard wall-clock ceiling.
/// <para><b>Why the ceiling is <c>Task.WaitAsync</c> and not just a linked
/// <c>CancelAfter</c>.</b> A linked token only bounds a
/// provider that <em>honours</em> it. The S7 R2-01 finding was exactly a provider whose async path
/// ignored its deadline, and ADO.NET has the same shape: some providers implement
/// <c>ExecuteReaderAsync</c> synchronously, and even a genuinely async one can hang inside its own
/// cancellation handshake against a frozen socket. Both are used here: the linked token so a
/// well-behaved provider unwinds cleanly and releases its connection, and <c>WaitAsync</c> so
/// control returns at the deadline regardless.</para>
/// <para>The work is started on the thread pool so a provider that blocks synchronously blocks a
/// pool thread rather than the caller — without that, a synchronous provider never yields the task
/// there would be to bound.</para>
/// <para><b>Thread-pool scheduling and what a BadTimeout therefore means.</b> The group's clock
/// starts before <c>Task.Run</c>, so in principle a group could burn budget queueing for a pool
/// thread rather than waiting on the database. <c>TaskCreationOptions.LongRunning</c> (a dedicated
/// OS thread) was considered and <b>rejected</b>: it would create and tear down one thread per group
/// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost
/// paid against a hazard that cannot arise with the provider this driver actually ships.
/// <c>Microsoft.Data.SqlClient</c>'s async path is genuinely asynchronous: the delegate reaches its
/// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks
/// <em>zero</em> pool threads however long it stays frozen, and the driver cannot starve itself no
/// matter how high <c>maxConcurrentGroups</c> goes. The queueing scenario needs a provider that
/// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose,
/// and is not a shipped configuration. <b>Consequence for outage diagnosis (e.g. blackhole-testing a
/// paused SQL Server):</b> under <c>Microsoft.Data.SqlClient</c> a
/// <see cref="SqlStatusCodes.BadTimeout"/> means the database did not answer inside
/// <c>operationTimeout</c> — it cannot be an artefact of this driver's own pool pressure. Under a
/// synchronously-blocking provider it may also include queueing delay, so size
/// <c>maxConcurrentGroups</c> below the process's readily-available worker count there.</para>
/// <para><b>The concurrency slot is released by the work, not by the waiter.</b> A timed-out group
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
/// <c>maxConcurrentGroups</c> connections however long it stays frozen. The slot wait is itself
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
/// </summary>
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false))
return GroupResult.TimedOut;
var handedOff = false;
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<GroupResult> work;
try
{
var budget = Remaining(clock);
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
deadline.CancelAfter(budget);
work = Task.Run(
async () =>
{
try
{
return await QueryAsync(plan, deadline.Token).ConfigureAwait(false);
}
finally
{
_gate.Release();
deadline.Dispose();
}
},
CancellationToken.None);
handedOff = true;
}
finally
{
// Ownership of both the slot and the CTS transfers to the work task; release them here only on
// the paths where that task was never created.
if (!handedOff)
{
_gate.Release();
deadline.Dispose();
}
}
try
{
return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Our wall-clock bound fired while the provider was still inside the call.
return TimedOut(plan, work);
}
catch (OperationCanceledException)
{
// Either the caller pulled the plug, or our deadline fired and the provider DID honour the
// linked token. Both leave the work running with nobody to observe its fault.
if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work);
Detach(work);
throw;
}
}
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
private TimeSpan Remaining(Stopwatch clock)
{
var remaining = _operationTimeout - clock.Elapsed;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>Records the deadline breach and detaches the abandoned work.</summary>
private GroupResult TimedOut(SqlQueryPlan plan, Task work)
{
// The table is named for the same reason the contract warnings name it: under a partial outage this
// line repeats every poll, and "which source is frozen" is the only question the operator has.
_logger.LogWarning(
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
"timeout; publishing BadTimeout.",
plan.Model, plan.Members[0].Table, plan.Members.Count,
(int)_operationTimeout.TotalMilliseconds);
Detach(work);
return GroupResult.TimedOut;
}
/// <summary>
/// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task
/// exception. The task still owns its connection and still releases the concurrency slot when it
/// finally completes — that is what keeps a frozen database from accumulating connections.
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
/// <summary>
/// Opens a connection, executes the plan's single command, and materialises the result set.
/// <para>The rows are read into memory <b>before</b> the reader is disposed — the slice-back needs
/// random access across members, and holding a reader (and therefore a connection) open across that
/// work is exactly how a poll loop exhausts a pool.</para>
/// </summary>
private async Task<GroupResult> QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"The {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
// Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which
// is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff).
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
try
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = plan.SqlText;
command.CommandTimeout = _commandTimeoutSeconds;
for (var i = 0; i < plan.ParameterNames.Count; i++)
{
var parameter = command.CreateParameter();
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
parameter.ParameterName = plan.ParameterNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
command.Parameters.Add(parameter);
}
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// 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). That is this group's problem alone.
_logger.LogWarning(
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
plan.Model, plan.Members.Count, plan.SqlText);
return GroupResult.Failed;
}
}
}
/// <summary>
/// Reads every row into memory, projected onto <see cref="SqlQueryPlan.SelectedColumns"/>. Row
/// fetching stays on the async path so a result set that streams slowly still honours the deadline
/// token mid-read, rather than only at the ExecuteReader boundary.
/// </summary>
private async Task<GroupResult> MaterialiseAsync(
SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken)
{
var selected = plan.SelectedColumns;
var ordinals = new int[selected.Count];
var typeNames = new string[selected.Count];
var columnIndex = new Dictionary<string, int>(selected.Count, StringComparer.Ordinal);
for (var c = 0; c < selected.Count; c++)
{
// GetOrdinal rather than the position in SelectedColumns: the list is documented to be in
// ordinal order, but resolving it against the live result set is what makes a wrong slice
// impossible rather than merely unlikely.
ordinals[c] = reader.GetOrdinal(selected[c]);
typeNames[c] = SafeTypeName(reader, ordinals[c]);
columnIndex[selected[c]] = c;
}
var rows = new List<object?[]>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var row = new object?[selected.Count];
for (var c = 0; c < selected.Count; c++)
row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]);
rows.Add(row);
}
return GroupResult.Ok(rows, columnIndex, typeNames);
}
/// <summary>Reads a column's provider type name, falling back to the dialect's unknown-type default.</summary>
private static string SafeTypeName(DbDataReader reader, int ordinal)
{
try
{
return reader.GetDataTypeName(ordinal) ?? string.Empty;
}
catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException
or IndexOutOfRangeException)
{
// A provider that declines to report a type name must not fail the poll — the tag's declared
// type (or the dialect's String fallback) covers it.
return string.Empty;
}
}
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
private void Slice(
SqlQueryPlan plan,
GroupResult outcome,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt)
{
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
// they are group-wide facts, and doing them per member would report a contract violation N times.
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
? IndexByKey(plan, outcome)
: null;
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
? SelectWideRow(plan, outcome)
: null;
foreach (var member in plan.Members)
{
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
if (!slots.TryGetValue(member, out var positions)) continue;
var snapshot = outcome.Succeeded
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
}
/// <summary>
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
/// violation is reported through a rate-limited warning rather than silently.
/// </summary>
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
{
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
return index;
var duplicated = 0;
foreach (var row in outcome.Rows)
{
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
// matched by a tag whose keyValue is legitimately the empty string.
if (row[keyAt] is not { } keyCell) continue;
// Stringified because the bound keyValue is authored text while the column may be any type
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
if (key is null) continue;
if (!index.TryAdd(key, row))
{
index[key] = row;
duplicated++;
}
}
if (duplicated > 0 && ShouldWarnAboutContract())
{
_logger.LogWarning(
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
"last row wins. The key-value model requires one row per key — point the tag at a " +
"current-values table or an operator-authored latest-per-key view.",
plan.Members[0].Table, duplicated);
}
return index;
}
/// <summary>
/// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector.
/// <para><b>Last row wins</b> — the same tie-break <see cref="IndexByKey"/> applies, so both models
/// degrade the same way. It is deterministic only <em>within one result set</em>: the where-pair form
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
/// (<c>Rows.Count &gt; 1</c>), converting a loud misconfiguration into a permanently silent one —
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
/// form keeps its row limit because there the <c>ORDER BY</c> makes "the first row" a defined
/// answer. The accepted cost is that an ambiguous selector materialises every matching row for one
/// poll; the warning is what gets it fixed.</para>
/// </summary>
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
{
if (outcome.Rows.Count == 0) return null;
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
{
var first = plan.Members[0];
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
? string.Concat("topByTimestamp ", first.RowSelectorTopByTimestamp)
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
_logger.LogWarning(
"Sql poll source '{Table}' returned {Rows} rows for the wide-row selector '{Selector}' in " +
"one poll; the last row read wins, and the query carries no ORDER BY, so which row that is " +
"depends on storage order. The wide-row model requires a selector matching exactly one row " +
"— narrow the whereColumn/whereValue pair, or point the tag at a latest-row view.",
first.Table, outcome.Rows.Count, selector);
}
return outcome.Rows[^1];
}
/// <summary>Maps one member's cell to a snapshot: value, quality, and source timestamp.</summary>
private DataValueSnapshot MapMember(
SqlQueryPlan plan,
SqlTagDefinition member,
GroupResult outcome,
Dictionary<string, object?[]>? byKey,
object?[]? wideRow,
DateTime readAt)
{
object?[]? row;
string? valueColumn;
string? timestampColumn;
if (plan.Model == SqlTagModel.KeyValue)
{
valueColumn = plan.ValueColumn;
timestampColumn = plan.TimestampColumn;
row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null;
}
else
{
valueColumn = member.ColumnName;
// A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are
// selected), which is why the plan-level TimestampColumn is null for this model by design.
timestampColumn = member.TimestampColumn;
row = wideRow;
}
// No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell.
if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt);
// A timestamp that will not parse falls back to the poll clock rather than poisoning a good value.
var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt;
if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt))
{
// The planner selects every member's value column, so this is unreachable short of plan/result
// drift — loud rather than silently publishing someone else's cell.
_logger.LogError(
"Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.",
member.Name, valueColumn);
return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt);
}
var cell = row[valueAt];
if (cell is null)
{
return new DataValueSnapshot(
null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt);
}
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
if (!TryCoerce(cell, target, out var value))
{
_logger.LogWarning(
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
member.Name, cell.GetType().Name, target);
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
}
return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt);
}
/// <summary>Reads a row's source timestamp, or null when there is no column, no value, or no parse.</summary>
private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn)
{
if (string.IsNullOrWhiteSpace(timestampColumn)) return null;
if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null;
if (row[at] is not { } cell) return null;
return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null;
}
/// <summary>
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
/// type matches the type the address space declared for the node. Never throws.
/// </summary>
private static bool TryCoerce(object cell, DriverDataType target, out object? value)
{
try
{
value = target switch
{
DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture),
DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture),
DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture),
DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture),
DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture),
// decimal/numeric collapse here, with the documented precision caveat (design §8.6).
DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture),
DriverDataType.DateTime => ToUtc(cell),
_ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty,
};
return true;
}
catch (Exception ex) when (ex is InvalidCastException or FormatException
or OverflowException or ArgumentException)
{
value = null;
return false;
}
}
/// <summary>
/// Normalises a timestamp cell to UTC. A naive <c>datetime</c> (no offset — the common SQL Server
/// shape) is <b>assumed</b> to already be UTC rather than reinterpreted through the server's local
/// zone, which would silently shift every source timestamp by the host's offset.
/// </summary>
private static DateTime ToUtc(object cell) => cell switch
{
DateTime { Kind: DateTimeKind.Utc } utc => utc,
DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(),
DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc),
DateTimeOffset offset => offset.UtcDateTime,
string text => DateTime.Parse(
text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
_ => DateTime.SpecifyKind(
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
};
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
private bool ShouldWarnAboutContract()
{
var now = Stopwatch.GetTimestamp();
var last = Interlocked.Read(ref _lastContractWarningTicks);
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
}
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
private sealed class GroupResult
{
private static readonly IReadOnlyList<object?[]> NoRows = [];
private static readonly IReadOnlyDictionary<string, int> NoColumns =
new Dictionary<string, int>(StringComparer.Ordinal);
private GroupResult(
uint failureStatus,
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
{
FailureStatus = failureStatus;
Rows = rows;
ColumnIndex = columnIndex;
TypeNames = typeNames;
}
/// <summary>The group exceeded its wall-clock deadline (design §8.3).</summary>
public static GroupResult TimedOut { get; } =
new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []);
/// <summary>The connection was fine but the query was not.</summary>
public static GroupResult Failed { get; } =
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
public uint FailureStatus { get; }
/// <summary>The materialised rows, projected onto the plan's selected columns.</summary>
public IReadOnlyList<object?[]> Rows { get; }
/// <summary>Selected column name → its position within each row array.</summary>
public IReadOnlyDictionary<string, int> ColumnIndex { get; }
/// <summary>Each selected column's provider type name, for dialect type inference.</summary>
public IReadOnlyList<string> TypeNames { get; }
/// <summary>True when the query ran; a zero-row result set is still a success.</summary>
public bool Succeeded => FailureStatus == SqlStatusCodes.Good;
/// <summary>A completed result set.</summary>
/// <param name="rows">The materialised rows.</param>
/// <param name="columnIndex">Selected column name → row-array position.</param>
/// <param name="typeNames">Each selected column's provider type name.</param>
/// <returns>A successful outcome.</returns>
public static GroupResult Ok(
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
=> new(SqlStatusCodes.Good, rows, columnIndex, typeNames);
}
}
/// <summary>
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
/// them back with.
/// <para><b>Why a driver-local table rather than a shared one.</b> Every driver in this tree carries its
/// own (<c>TwinCATStatusMapper</c>, <c>AbCipStatusMapper</c>, <c>FocasStatusMapper</c>,
/// <c>AbLegacyStatusMapper</c>, Galaxy's <c>StatusCodeMap</c>) — there is no shared helper in
/// <c>Core.Abstractions</c>, because drivers deliberately do not reference the OPC UA SDK and
/// <see cref="DataValueSnapshot.StatusCode"/> is a bare <see cref="uint"/>. The values below were read
/// off <c>Opc.Ua.StatusCodes</c> rather than copied from a sibling driver, since two of those tables
/// carry transcription errors.</para>
/// </summary>
public static class SqlStatusCodes
{
/// <summary>The value is good. (<c>Good</c>)</summary>
public const uint Good = 0x00000000u;
/// <summary>Quality is uncertain with no more specific reason — a NULL cell under <c>nullIsBad=false</c>.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Quality is bad with no more specific reason — a NULL cell under <c>nullIsBad=true</c>.</summary>
public const uint Bad = 0x80000000u;
/// <summary>
/// No row was returned for this tag — the key is absent from the result set, or the wide-row
/// selector matched nothing. <b>Deliberately distinct from a NULL cell:</b> the row is gone, versus
/// the row is there and the value is not.
/// </summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>The group exceeded its client-side wall-clock deadline (design §8.3).</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>The reference resolves to no authored tag.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The query failed after the connection opened.</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The cell could not be coerced to the tag's effective data type.</summary>
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>A tag definition the planner rejected — an authoring fault, not a database fault.</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>A defect in the reader itself; never expected in the field.</summary>
public const uint BadInternalError = 0x80020000u;
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
private const uint SeverityMask = 0xC0000000u;
/// <summary>True when <paramref name="statusCode"/> is in the Good quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Good.</returns>
public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u;
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Uncertain.</returns>
public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain;
/// <summary>True when <paramref name="statusCode"/> is in the Bad quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Bad.</returns>
public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad;
}
@@ -0,0 +1,90 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
/// shares a <see cref="GroupKey"/> reads from the <b>same</b> result set, so a poll pass executes one plan
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
/// <param name="Model">
/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
/// <see cref="SqlTagModel.KeyValue"/> indexes rows by <see cref="KeyColumn"/> and matches each member's
/// <see cref="SqlTagDefinition.KeyValue"/>; <see cref="SqlTagModel.WideRow"/> takes the single row and
/// reads each member's <see cref="SqlTagDefinition.ColumnName"/>.
/// </param>
/// <param name="GroupKey">
/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly
/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing.
/// </param>
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
/// <param name="ParameterNames">
/// The parameter markers appearing in <see cref="SqlText"/>, in the order they appear, aligned index-for-index
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
/// may legitimately read the same cell, and both must receive a value.
/// </param>
/// <param name="SelectedColumns">
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// </param>
public sealed record SqlQueryPlan(
SqlTagModel Model,
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
{
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
// mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary.
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
/// <summary>The tags this plan feeds, in input order; duplicates preserved.</summary>
public IReadOnlyList<SqlTagDefinition> Members { get; } = [.. Members];
/// <summary>The bare SELECT-list column names, in result-set ordinal order.</summary>
public IReadOnlyList<string> SelectedColumns { get; } = [.. SelectedColumns];
/// <summary>
/// The key column all members are matched on — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant,
/// so <c>Members[0]</c> is authoritative.
/// </summary>
public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
/// <summary>
/// The column every member's value is read from — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant.
/// </summary>
public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
/// <summary>
/// The shared source-timestamp column, or <see langword="null"/> when the group has none (the reader
/// then stamps the poll time). <see cref="SqlTagModel.KeyValue"/> only — it is part of that model's
/// group key, so it is uniform. A <see cref="SqlTagModel.WideRow"/> plan deliberately allows members to
/// carry <em>different</em> timestamp columns (all of them are selected), so the reader must read
/// <see cref="SqlTagDefinition.TimestampColumn"/> per member there.
/// </summary>
public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
}
@@ -0,0 +1,159 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class SqlServerDialect : ISqlDialect
{
/// <summary>
/// T-SQL's regular-identifier ceiling (<c>sysname</c> = <c>nvarchar(128)</c>). Enforced because a
/// longer string cannot name a real catalog object — it is either a config mistake or padding, and
/// rejecting it keeps the quoted output bounded.
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.SqlServer;
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
/// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
/// separator at the call site.
/// </summary>
public string SingleRowLimitPrefix => "TOP 1 ";
/// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
/// <summary>
/// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
/// </summary>
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
/// <inheritdoc/>
public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
/// <inheritdoc/>
public string ListColumnsSql =>
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION";
/// <summary>
/// Brackets one identifier part, doubling any embedded <c>]</c> — the T-SQL escape rule, and the only
/// metacharacter that could terminate a bracketed identifier early. <c>[</c>, quotes, semicolons and
/// comment markers are inert inside brackets and are therefore passed through unchanged.
/// </summary>
/// <remarks>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="System.Globalization.UnicodeCategory.Format"/> character — bidi overrides, zero-width
/// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the <em>rendered</em>
/// text of a log line or an AdminUI label while comparing byte-different from the real catalog name
/// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the
/// control-character rule.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The bracket-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQL Server identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
if (ident.Length > MaxIdentifierLength)
throw new ArgumentException(
$"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
// zero-width character. They are inert inside brackets but spoof rendered log/UI text.
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
throw new ArgumentException(
"A SQL identifier may not contain Unicode format characters "
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
}
return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]");
}
/// <summary>
/// Folds an <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> family name onto a
/// <see cref="DriverDataType"/> per design §3.7.
/// </summary>
/// <remarks>
/// <para><b>Precision caveat:</b> <c>decimal</c> / <c>numeric</c> / <c>money</c> / <c>smallmoney</c>
/// collapse to <see cref="DriverDataType.Float64"/> in v1. A .NET <c>decimal</c> carries more
/// significant digits than a <c>double</c>, so a high-precision column (e.g. <c>decimal(38,10)</c>)
/// loses low-order digits. Authoring an explicit <c>"type": "String"</c> on the tag preserves the exact
/// textual value when that matters.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>xml</c>,
/// <c>geography</c>, <c>hierarchyid</c>, <c>sql_variant</c>, a CLR UDT, <c>varbinary</c>) rather than
/// crash on it. Null/blank input takes the same fallback.</para>
/// <para><b>Deliberate non-mappings:</b> SQL Server's <c>timestamp</c>/<c>rowversion</c> is an opaque
/// 8-byte row version, <em>not</em> a date — it falls back to <see cref="DriverDataType.String"/>
/// rather than being mistaken for a <see cref="DriverDataType.DateTime"/>. <c>time</c> is a
/// time-of-day span, likewise not a point in time, and takes the same fallback.</para>
/// <para><c>tinyint</c> widens to <see cref="DriverDataType.Int16"/> (it is unsigned 0255 in T-SQL, so
/// a signed 8-bit type would not hold it, and the driver type set has no <c>Byte</c>).</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"bit" or "boolean" => DriverDataType.Boolean,
"tinyint" or "smallint" => DriverDataType.Int16,
"int" or "integer" => DriverDataType.Int32,
"bigint" => DriverDataType.Int64,
"real" => DriverDataType.Float32,
"float" or "double" or "double precision"
or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64,
"char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext"
or "uniqueidentifier" => DriverDataType.String,
"date" or "datetime" or "datetime2" or "smalldatetime"
or "datetimeoffset" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadOutOfService = 0x80BE0000u;
public const uint BadInvalidState = 0x80350000u;
public const uint BadTypeMismatch = 0x80740000u;
public const uint BadOutOfService = 0x808D0000u;
public const uint BadInvalidState = 0x80AF0000u;
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
@@ -81,7 +81,7 @@ else
<tr>
<td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
<td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
@@ -58,7 +58,7 @@ else
}
else
{
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
@if (_lastDeployment.SealedAtUtc is not null)
@@ -55,7 +55,7 @@
{
<tr>
<td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td>
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td>
<td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td>
@@ -113,7 +113,7 @@
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch
{
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.",
@@ -38,7 +38,7 @@ else
<span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Rendering helpers for operator-facing text that comes out of the database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
/// </para>
/// <para>
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
/// <c>h1…</c>, so the display never implies more text than exists.
/// </para>
/// </remarks>
public static class DisplayText
{
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
/// Admin UI already uses for "nothing to show" cells.</summary>
public const string Empty = "—";
/// <summary>
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
/// ellipsis when (and only when) characters were dropped.
/// </summary>
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
/// <paramref name="maxLength"/> — none of which throw.</param>
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
/// so a caller cannot turn a display bug into a crash.</param>
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
/// otherwise the truncated prefix plus an ellipsis.</returns>
public static string Abbreviate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return Empty;
if (maxLength < 1) maxLength = 1;
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
}
}
@@ -57,6 +57,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -36,6 +36,7 @@
<option value="Focas">Focas</option>
<option value="OpcUaClient">OpcUaClient</option>
<option value="GalaxyMxGateway">Galaxy</option>
<option value="Sql">Sql</option>
</InputSelect>
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div>
@@ -0,0 +1,198 @@
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
factory's string-typed deserialize accepts them. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Database connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Provider</label>
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
</InputSelect>
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
</div>
<div class="col-md-8">
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="MesStaging" />
<div class="form-text">
Name of the <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var set on the driver + AdminUI hosts —
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
</div>
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
{
<div class="text-danger small mt-1">Connection string ref is required.</div>
}
</div>
</div>
</div>
</section>
@* Polling / timeouts *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Polling &amp; timeouts</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Default poll interval (s)</label>
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
</div>
<div class="col-md-3">
<label class="form-label">Command timeout (s)</label>
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max concurrent groups</label>
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
</div>
</div>
</div>
</section>
@* Value handling *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Value handling</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
</div>
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
</div>
<div class="form-text mt-0">
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
// drops the composer-owned rawTags (never authored here).
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
_form = FormModel.FromDto(dto);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static SqlDriverConfigDto? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
catch { return null; }
}
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
public sealed class FormModel
{
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
public string? ConnectionStringRef { get; set; }
public int? DefaultPollIntervalSeconds { get; set; }
public int? OperationTimeoutSeconds { get; set; }
public int? CommandTimeoutSeconds { get; set; }
public int? MaxConcurrentGroups { get; set; }
public bool NullIsBad { get; set; }
public static FormModel FromDto(SqlDriverConfigDto d) => new()
{
Provider = d.Provider,
ConnectionStringRef = d.ConnectionStringRef,
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
MaxConcurrentGroups = d.MaxConcurrentGroups,
NullIsBad = d.NullIsBad ?? false,
};
public SqlDriverConfigDto ToDto() => new()
{
Provider = Provider,
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
MaxConcurrentGroups = MaxConcurrentGroups,
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
// and rawTags are deliberately left null so they are omitted from the authored blob.
NullIsBad = NullIsBad ? true : null,
};
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
}
}
@@ -0,0 +1,441 @@
@* Sql address picker (read-only v1):
1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the
table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a
server the AdminUI can't reach, authors a tag entirely from these fields.
2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the
picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills
`table`, the value/column field, and the inferred `type`.
The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the
shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/
`type` enums are matched to the parser precisely; a mismatch would author fine and never read.
Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the
JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed
TagConfig blob nor back into the driver config — there is no code path here that persists it. *@
@implements IAsyncDisposable
@using System.Text.Json.Nodes
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Sql
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Model</label>
<select class="form-select form-select-sm" @bind="_model" @bind:after="OnManualChangedAsync">
<option value="KeyValue">KeyValue</option>
<option value="WideRow">WideRow</option>
</select>
<div class="form-text">
KeyValue: one row per key. WideRow: many columns of one selected row.
</div>
</div>
<div class="col-md-5">
<label class="form-label">Table or view</label>
<input type="text" class="form-control form-control-sm mono" placeholder="dbo.TagValues"
@bind="_table" @bind:after="OnManualChangedAsync" />
<div class="form-text">Schema-qualified, e.g. <code>dbo.TagValues</code>.</div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="OnManualChangedAsync">
<option value="">(infer)</option>
@foreach (var t in Enum.GetValues<DriverDataType>())
{
<option value="@t">@t</option>
}
</select>
<div class="form-text">Blank ⇒ inferred from the column.</div>
</div>
</div>
@if (_model == "KeyValue")
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Key column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="tag_name"
@bind="_keyColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-4">
<label class="form-label">Key value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Line1.Speed"
@bind="_keyValue" @bind:after="OnManualChangedAsync" />
<div class="form-text">Bound as a parameter — never interpolated.</div>
</div>
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="num_value"
@bind="_valueColumn" @bind:after="OnManualChangedAsync" />
</div>
</div>
}
else
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="oven_temp"
@bind="_columnName" @bind:after="OnManualChangedAsync" />
<div class="form-text">The column this tag reads from the selected row.</div>
</div>
<div class="col-md-4">
<label class="form-label">Row selector</label>
<select class="form-select form-select-sm" @bind="_selectorMode" @bind:after="OnManualChangedAsync">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select>
</div>
@if (_selectorMode == "Where")
{
<div class="col-md-2">
<label class="form-label">Where column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="station_id"
@bind="_whereColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">Where value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="7"
@bind="_whereValue" @bind:after="OnManualChangedAsync" />
</div>
}
else
{
<div class="col-md-4">
<label class="form-label">Order-by (timestamp) column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_topByTimestamp" @bind:after="OnManualChangedAsync" />
</div>
}
</div>
}
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_timestampColumn" @bind:after="OnManualChangedAsync" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div>
</div>
</div>
@if (_canOperate)
{
<hr class="my-3" />
<div class="row g-3">
<div class="col-md-12">
<label class="form-label small">Ad-hoc connection string <span class="text-muted">(session-only, never saved)</span></label>
<input type="password" class="form-control form-control-sm mono" autocomplete="off"
placeholder="Server=…;Database=…;User Id=…;Password=…"
@bind="_adhocConnectionString" />
<div class="form-text">
Leave blank to browse via the driver's <code>connectionStringRef</code> (resolved from
the AdminUI host's <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var). Paste a
literal only for a one-off browse — it is used for this session and never persisted.
</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
@onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse database
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
</div>
@if (_openError is not null)
{
<div class="text-danger small mt-2">@_openError</div>
}
@if (_token != Guid.Empty)
{
<div class="row g-3 mt-1">
<div class="col-md-7">
<label class="form-label small">Schemas → tables → columns</label>
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
SelectedNodeId="@_selectedColumnNodeId" />
</div>
<div class="col-md-5">
<label class="form-label small">Picked column</label>
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_attrsLoading)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else if (_attrsError is not null)
{
<span class="text-danger small">@_attrsError</span>
}
else if (_attrs is null)
{
<span class="text-muted small">Pick a column leaf.</span>
}
else if (_attrs.Count == 0)
{
<span class="text-muted small">Column no longer present.</span>
}
else
{
@foreach (var a in _attrs)
{
<div class="d-flex justify-content-between align-items-center py-1"
style="cursor:pointer" @onclick="@(() => ApplyAttributeAsync(a))">
<span class="mono small">@a.Name</span>
<span class="text-muted small">@a.DriverDataType · @a.SecurityClass</span>
</div>
}
}
</div>
</div>
</div>
}
}
<div class="mt-3">
<span class="text-muted small">TagConfig:</span>
<code class="mono ms-2">@(_built.Length == 0 ? "—" : _built)</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
/// <summary>Live accessor for the selected driver's <c>DriverConfig</c> JSON (carries
/// <c>connectionStringRef</c> + <c>provider</c>). Fed to <c>BrowserService.OpenAsync("Sql", …)</c>.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Fires with the picker's suggested tag display name (<c>table.column</c>) whenever a
/// column is picked from the tree, so a host editor may default the raw tag's name. Optional —
/// the composed TagConfig blob itself carries no display name (it is not a parser field).</summary>
[Parameter] public EventCallback<string> SuggestedDisplayNameChanged { get; set; }
// Manual/composed tag fields (all editable by hand; browse only prefills a subset).
private string _model = "KeyValue";
private string _table = "";
private string _keyColumn = "";
private string _keyValue = "";
private string _valueColumn = "";
private string _columnName = "";
private string _selectorMode = "Where";
private string _whereColumn = "";
private string _whereValue = "";
private string _topByTimestamp = "";
private string _timestampColumn = "";
private string _type = "";
// Browse-only, session-scoped state.
private string _adhocConnectionString = "";
private string _built = "";
private string _selectedColumnNodeId = "";
private Guid _token = Guid.Empty;
private bool _opening;
private bool _canOperate;
private string? _openError;
private bool _attrsLoading;
private string? _attrsError;
private IReadOnlyList<AttributeInfo>? _attrs;
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OpenBrowseAsync()
{
_opening = true;
_openError = null;
StateHasChanged();
try
{
// Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
finally { _opening = false; StateHasChanged(); }
}
/// <summary>
/// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one,
/// a top-level <c>connectionString</c> literal. The literal wins over any <c>connectionStringRef</c>
/// server-side (SqlDriverBrowser precedence) and is session-only.
/// </summary>
private string BuildBrowseConfig()
{
var baseJson = GetConfigJson() ?? "{}";
if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; }
JsonObject o;
try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); }
catch (System.Text.Json.JsonException) { o = new JsonObject(); }
o["connectionString"] = _adhocConnectionString;
return o.ToJsonString();
}
private async Task CloseBrowseAsync()
{
var t = _token;
_token = Guid.Empty;
_attrs = null;
_selectedColumnNodeId = "";
StateHasChanged();
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
}
/// <summary>
/// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are
/// navigation, not selection. The NodeId is decoded with <see cref="SqlBrowseNodeId.TryParse"/>
/// — never split by hand, because SQL identifiers may contain '.' and '|'.
/// </summary>
private async Task OnTreeSelectAsync(BrowseNode node)
{
if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference)
|| reference.Kind != SqlBrowseNodeKind.Column)
{
return;
}
_selectedColumnNodeId = node.NodeId;
_table = $"{reference.Schema}.{reference.Table}";
if (_model == "WideRow") { _columnName = reference.Column!; }
else { _valueColumn = reference.Column!; }
// Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified).
await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}");
_attrs = null;
_attrsLoading = true;
_attrsError = null;
StateHasChanged();
try
{
_attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default);
var picked = _attrs.Count > 0 ? _attrs[0] : null;
if (picked is not null) { PrefillType(picked); }
}
catch (Exception ex) { _attrsError = ex.Message; }
finally
{
_attrsLoading = false;
await OnChangedAsync();
}
}
/// <summary>Re-applies the side-panel column's inferred type (and value-column) on click.</summary>
private async Task ApplyAttributeAsync(AttributeInfo a)
{
if (_model == "WideRow") { _columnName = a.Name; }
else { _valueColumn = a.Name; }
PrefillType(a);
await OnChangedAsync();
}
/// <summary>Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps.</summary>
private void PrefillType(AttributeInfo a)
{
if (Enum.TryParse<DriverDataType>(a.DriverDataType, out _)) { _type = a.DriverDataType; }
}
private async Task OnManualChangedAsync() => await OnChangedAsync();
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
/// <summary>
/// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to
/// <c>SqlEquipmentTagParser</c>. Returns "" until the selected model's required fields are all
/// present, so the "Use this address" button never commits a half-blob the parser would reject.
/// <c>model</c> and <c>type</c> are written as NAME strings (the parser reads them strictly).
/// </summary>
private string Build()
{
var table = _table.Trim();
if (table.Length == 0) { return ""; }
var o = new JsonObject
{
["driver"] = "Sql",
["model"] = _model,
["table"] = table,
};
if (_model == "KeyValue")
{
var keyColumn = _keyColumn.Trim();
var valueColumn = _valueColumn.Trim();
var keyValue = _keyValue.Trim();
if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; }
o["keyColumn"] = keyColumn;
o["keyValue"] = keyValue;
o["valueColumn"] = valueColumn;
}
else // WideRow
{
var columnName = _columnName.Trim();
if (columnName.Length == 0) { return ""; }
o["columnName"] = columnName;
var rowSelector = new JsonObject();
if (_selectorMode == "Where")
{
var whereColumn = _whereColumn.Trim();
var whereValue = _whereValue.Trim();
if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; }
rowSelector["whereColumn"] = whereColumn;
rowSelector["whereValue"] = whereValue;
}
else // Newest
{
var topByTimestamp = _topByTimestamp.Trim();
if (topByTimestamp.Length == 0) { return ""; }
rowSelector["topByTimestamp"] = topByTimestamp;
}
o["rowSelector"] = rowSelector;
}
var timestampColumn = _timestampColumn.Trim();
if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; }
if (_type.Length > 0) { o["type"] = _type; }
// v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side).
o["writable"] = false;
return o.ToJsonString();
}
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow database.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
}
@@ -68,6 +68,7 @@
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
];
@@ -0,0 +1,211 @@
@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by
TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Model</label>
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
<option value="@SqlTagModel.KeyValue">KeyValue</option>
<option value="@SqlTagModel.WideRow">WideRow</option>
</select></div>
<div class="col-md-5"><label class="form-label">Table or view</label>
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Type override</label>
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(infer from column)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@if (_m.Model == SqlTagModel.KeyValue)
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Key column</label>
<input class="form-control form-control-sm mono" value="@_m.KeyColumn" placeholder="tag_name" @onchange="@(e => Update(() => _m.KeyColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-4"><label class="form-label">Key value</label>
<input class="form-control form-control-sm mono" value="@_m.KeyValue" placeholder="Line1.Speed" @onchange="@(e => Update(() => _m.KeyValue = e.Value?.ToString()))" />
<div class="form-text">Bound as a parameter — never interpolated.</div></div>
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ValueColumn" placeholder="num_value" @onchange="@(e => Update(() => _m.ValueColumn = NullIfBlank(e.Value)))" /></div>
</div>
}
else
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ColumnName" placeholder="oven_temp" @onchange="@(e => Update(() => _m.ColumnName = NullIfBlank(e.Value)))" />
<div class="form-text">The column this tag reads from the selected row.</div></div>
<div class="col-md-4"><label class="form-label">Row selector</label>
<select class="form-select form-select-sm" value="@_selectorMode" @onchange="@(e => OnSelectorModeChanged(e.Value?.ToString()))">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select></div>
@if (_selectorMode == "Where")
{
<div class="col-md-2"><label class="form-label">Where column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereColumn" placeholder="station_id" @onchange="@(e => Update(() => _m.RowSelector.WhereColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">Where value</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereValue" placeholder="7" @onchange="@(e => Update(() => _m.RowSelector.WhereValue = e.Value?.ToString()))" /></div>
}
else
{
<div class="col-md-4"><label class="form-label">Order-by (timestamp) column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.TopByTimestamp" placeholder="sample_ts" @onchange="@(e => Update(() => _m.RowSelector.TopByTimestamp = NullIfBlank(e.Value)))" /></div>
}
</div>
}
<div class="row g-2 mt-1">
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div></div>
<div class="col-12 mt-1">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
</div>
</div>
@if (_showPicker)
{
<DriverTagPicker @bind-Visible="_showPicker"
Title="Sql tag builder"
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
<SqlAddressPickerBody CurrentAddress="@_pickerAddress"
CurrentAddressChanged="@((s) => _pickerAddress = s)"
GetConfigJson="@GetDriverConfigJson" />
</DriverTagPicker>
}
@code {
/// <summary>The tag's current TagConfig JSON.</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Raised with the updated TagConfig JSON when the model changes.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private SqlTagConfigModel _m = new();
private string? _lastConfigJson;
private bool _showPicker;
private string _pickerAddress = "";
// Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
// carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in
// the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state
// the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously
// says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where".
private string _selectorMode = "Where";
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server
// live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors).
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = SqlTagConfigModel.FromJson(ConfigJson);
DeriveSelectorMode();
}
// Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves
// the operator's current choice untouched when both selector fields are blank (an in-progress edit).
private void DeriveSelectorMode()
{
if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
{
_selectorMode = "Newest";
}
else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
{
_selectorMode = "Where";
}
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by
// NAME (never an ordinal cast), matching the model's strict name-string serialisation.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip
// is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
{
var s = v?.ToString();
if (string.IsNullOrEmpty(s)) { return null; }
return Enum.TryParse<TEnum>(s, out var r) ? r : null;
}
// Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the
// key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value
// as present-and-empty, distinct from absent, so those keep e.Value verbatim.)
private static string? NullIfBlank(object? v)
{
var s = v?.ToString();
return string.IsNullOrWhiteSpace(s) ? null : s;
}
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
// The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of
// the mode being left so the composed blob carries exactly one selector (a stale where-pair would win
// over topByTimestamp at read time, silently overriding the operator's choice).
private async Task OnSelectorModeChanged(string? mode)
{
_selectorMode = mode == "Newest" ? "Newest" : "Where";
await Update(() =>
{
if (_selectorMode == "Newest")
{
_m.RowSelector.WhereColumn = null;
_m.RowSelector.WhereValue = null;
}
else
{
_m.RowSelector.TopByTimestamp = null;
}
});
}
// The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the
// other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working
// model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that
// the picker never authors.
private async Task OnAddressPicked(string blob)
{
if (string.IsNullOrWhiteSpace(blob)) { return; }
var picked = SqlTagConfigModel.FromJson(blob);
await Update(() =>
{
_m.Model = picked.Model;
_m.Table = picked.Table;
_m.KeyColumn = picked.KeyColumn;
_m.KeyValue = picked.KeyValue;
_m.ValueColumn = picked.ValueColumn;
_m.ColumnName = picked.ColumnName;
_m.TimestampColumn = picked.TimestampColumn;
_m.Type = picked.Type;
_m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn;
_m.RowSelector.WhereValue = picked.RowSelector.WhereValue;
_m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp;
});
DeriveSelectorMode();
}
}
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
// ctor param has a production default. The universal-browser DI line is deliberately NOT added
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
@@ -0,0 +1,212 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column
/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the
/// authoritative runtime reader <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
/// (top-level and inside <c>rowSelector</c>) across a load→save so a future field survives an edit.
/// </summary>
public sealed class SqlTagConfigModel
{
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
public string? Table { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
public string? KeyColumn { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
public string? KeyValue { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
public string? ValueColumn { get; set; }
/// <summary>Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.</summary>
public string? TimestampColumn { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</summary>
public string? ColumnName { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
public SqlRowSelectorModel RowSelector { get; set; } = new();
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
/// driver infers the type from the result-set column metadata.</summary>
public DriverDataType? Type { get; set; }
private JsonObject _bag = new();
// The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's
// strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate).
private string? _invalidModelRaw;
private string? _invalidTypeRaw;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="SqlTagConfigModel"/>.</returns>
public static SqlTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var (model, invalidModel) = ReadEnumStrict<SqlTagModel>(o, "model");
var (type, invalidType) = ReadEnumStrict<DriverDataType>(o, "type");
var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null;
return new SqlTagConfigModel
{
Model = model ?? SqlTagModel.KeyValue,
Table = TagConfigJson.GetString(o, "table"),
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
KeyValue = TagConfigJson.GetString(o, "keyValue"),
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
Type = type,
_bag = o,
_invalidModelRaw = invalidModel,
_invalidTypeRaw = invalidType,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as
/// their name strings) over the preserved key bag. The nested <c>rowSelector</c> object is rebuilt from
/// <see cref="RowSelector"/> (preserving its own unknown keys) and omitted entirely when empty.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "model", Model);
TagConfigJson.Set(_bag, "table", Table);
TagConfigJson.Set(_bag, "keyColumn", KeyColumn);
TagConfigJson.Set(_bag, "keyValue", KeyValue);
TagConfigJson.Set(_bag, "valueColumn", ValueColumn);
TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn);
TagConfigJson.Set(_bag, "columnName", ColumnName);
TagConfigJson.Set(_bag, "type", Type);
var selector = RowSelector.ToJsonObject();
if (selector is null) { _bag.Remove("rowSelector"); }
else { _bag["rowSelector"] = selector; }
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the model against the exact accept/reject boundary of
/// <see cref="SqlEquipmentTagParser.TryParse"/>. Returns an error message, or <c>null</c> when valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
// Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag.
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
// Query is design §5.4 / P3 — the parser rejects it; the editor must too.
if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; }
if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; }
switch (Model)
{
case SqlTagModel.KeyValue:
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
return null;
case SqlTagModel.WideRow:
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
// A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but
// must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`.
var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null;
if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp))
{
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
}
return null;
default:
return "model is not a supported Sql tag model.";
}
}
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it.
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
where TEnum : struct, Enum
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var s))
{
return (null, null);
}
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
}
}
/// <summary>
/// Typed working model for a <see cref="SqlTagModel.WideRow"/> tag's nested <c>rowSelector</c> object.
/// Holds a where-pair (<see cref="WhereColumn"/>/<see cref="WhereValue"/>) or a newest-by-timestamp
/// selector (<see cref="TopByTimestamp"/>). Preserves unknown keys inside the selector across load→save.
/// </summary>
public sealed class SqlRowSelectorModel
{
/// <summary>The column that picks the row (matched against <see cref="WhereValue"/>).</summary>
public string? WhereColumn { get; set; }
/// <summary>The value <see cref="WhereColumn"/> is matched against (bound as a parameter at read time).
/// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring
/// the parser's scalar read.</summary>
public string? WhereValue { get; set; }
/// <summary>Newest-row selector: the timestamp column to order by (used when no where-pair is authored).</summary>
public string? TopByTimestamp { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a selector model from the nested <c>rowSelector</c> JSON object (or a fresh empty model
/// when the object is absent), retaining any unknown keys.</summary>
/// <param name="o">The nested <c>rowSelector</c> object, or <c>null</c> when absent.</param>
/// <returns>The populated <see cref="SqlRowSelectorModel"/>.</returns>
public static SqlRowSelectorModel FromJson(JsonObject? o)
{
// Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson).
var bag = o?.DeepClone().AsObject() ?? new JsonObject();
return new SqlRowSelectorModel
{
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
WhereValue = ReadScalarText(bag, "whereValue"),
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
_bag = bag,
};
}
/// <summary>Rebuilds the nested <c>rowSelector</c> JSON object from the exposed fields over the preserved
/// key bag, or returns <c>null</c> when the selector carries nothing (so the parent omits the key).</summary>
/// <returns>The <c>rowSelector</c> <see cref="JsonObject"/>, or <c>null</c> when empty.</returns>
public JsonObject? ToJsonObject()
{
var o = _bag.DeepClone().AsObject();
TagConfigJson.Set(o, "whereColumn", WhereColumn);
TagConfigJson.Set(o, "whereValue", WhereValue);
TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp);
return o.Count == 0 ? null : o;
}
// Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw
// token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar).
private static string? ReadScalarText(JsonObject o, string name)
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
}
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -21,6 +22,10 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -23,6 +24,8 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -39,6 +39,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
</ItemGroup>
</Project>
@@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe;
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
using SqlProbe = Driver.Sql.SqlDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
return services;
}
@@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
}
}
@@ -76,6 +76,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
</ItemGroup>
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
ReassertConditionNodes();
}
/// <summary>
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
/// its condition node, right after a (re)load.
///
/// <para>
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
/// re-derives Active from the predicate, so a still-active alarm ends the reload
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
/// client until its next real transition, which may never come. Same shape as the VirtualTag
/// redeploy-reset the <c>ReassertValue</c> fix closed.
/// </para>
///
/// <para>
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
/// </para>
///
/// <para>
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
/// already queued ahead of these writes: the node exists by the time they are handled.
/// </para>
///
/// <para>
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
/// matches what the materialise just built, so writing it would be a pure no-op on state —
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
/// display name; a projection carries the resolved template). Skipping them keeps a
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
/// at zero on the overwhelmingly common all-normal deploy.
/// </para>
///
/// <para>
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
/// like every other node write here: the Secondary keeps its address space warm for failover.
/// </para>
/// </summary>
private void ReassertConditionNodes()
{
var reasserted = 0;
foreach (var projection in _engine.GetProjections())
{
if (IsInNoEventPosition(projection.Condition))
{
continue;
}
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: projection.AlarmId,
State: ToSnapshot(projection),
// The instant the state we are re-asserting actually came about — NOT the deploy time.
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
// active, not when the address space happened to be rebuilt.
TimestampUtc: projection.Condition.LastTransitionUtc,
Realm: AddressSpaceRealm.Uns));
reasserted++;
}
if (reasserted > 0)
{
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
}
}
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
/// <param name="condition">The engine's current condition state.</param>
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
private static bool IsInNoEventPosition(AlarmConditionState condition)
=> condition.Enabled == AlarmEnabledState.Enabled
&& condition.Active == AlarmActiveState.Inactive
&& condition.Acked == AlarmAckedState.Acknowledged
&& condition.Confirmed == AlarmConfirmedState.Confirmed
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
{
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
Active: e.Condition.Active == AlarmActiveState.Active,
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(e.Condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(e.Severity),
Message: e.Message,
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
=> ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
/// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
/// to restore a condition node after a rebuild, never an emission. Shares
/// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
/// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
/// <param name="p">The engine projection to map.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
/// paths.</summary>
/// <param name="condition">The Part 9 condition state.</param>
/// <param name="severity">The engine's coarse severity bucket.</param>
/// <param name="message">The resolved condition message.</param>
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
Active: condition.Active == AlarmActiveState.Active,
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(severity),
Message: message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(e.WorstInputStatusCode));
Quality: QualityFromStatus(worstInputStatusCode));
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
/// <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
private const string Code = "SqlConnectionStringPersisted";
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
private const string LeakedConfig =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private static DriverInstance SqlDriver(string config) => new()
{
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
DriverType = "Sql", DriverConfig = config,
};
private static Device Device(string driverInstanceId, string config) => new()
{
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
};
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = [driver],
Devices = device is null ? [] : [device],
};
[Fact]
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
/// problem without repeating the credential it is refusing to store.
/// </summary>
[Fact]
public void Error_message_does_not_echo_the_credential()
{
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
error.Message.ShouldNotContain("hunter2");
error.Message.ShouldNotContain("Server=sql,1433");
error.Message.ShouldContain("connectionStringRef");
}
/// <summary>
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
/// </summary>
[Theory]
[InlineData("ConnectionString")]
[InlineData("CONNECTIONSTRING")]
[InlineData("connectionstring")]
public void Key_match_is_case_insensitive(string key)
{
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
DraftValidator.Validate(Draft(SqlDriver(config)))
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
/// there is the identical leak and must fail the same way.
/// </summary>
[Fact]
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
{
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
}
[Fact]
public void A_properly_authored_connectionStringRef_passes()
{
var draft = Draft(
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
Device("di-sql", """{"pollIntervalMs":1000}"""));
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
/// be a regression, not defence in depth.
/// </summary>
[Fact]
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
{
var modbus = new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = LeakedConfig,
};
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
/// </summary>
[Fact]
public void A_device_under_a_non_Sql_driver_is_not_flagged()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [Device("di-mb", LeakedConfig)],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
/// rule's job, and a parse failure here would take down every other check in the same pass.
/// </summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("\"connectionString\"")]
[InlineData("{\"provider\":\"SqlServer\"")]
public void Malformed_config_neither_throws_nor_flags(string config)
{
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
.ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
/// positive on, say, a tag blob that happens to describe a connection string.
/// </summary>
[Fact]
public void A_nested_connectionString_is_not_flagged()
{
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
}
}
@@ -0,0 +1,160 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards every driver's hard-coded OPC UA status-code constant against
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
/// <em>name</em> disagrees with its <em>value</em> fails here.
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
/// Gitea #497 found in six places across four drivers.</para>
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
/// </summary>
public sealed class StatusCodeParityTests
{
/// <summary>
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
/// that does not start with one of them is not a status constant.
/// </summary>
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
/// <summary>
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
/// </summary>
private static readonly string[] NamePrefixesToStrip = ["Status"];
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
typeof(Opc.Ua.StatusCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
/// <summary>
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
/// </summary>
/// <remarks>
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
/// modifiers say nothing about whether a value reaches an OPC UA client.
/// </remarks>
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
{
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type?[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
foreach (var type in types)
{
if (type is null) continue;
var fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
foreach (var field in fields)
{
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
var sdkName = StripKnownPrefix(field.Name);
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
data.Add(
asm.GetName().Name!,
type.FullName ?? type.Name,
field.Name,
sdkName,
(uint)field.GetRawConstantValue()!);
}
}
}
return data;
}
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
private static string StripKnownPrefix(string fieldName)
{
foreach (var prefix in NamePrefixesToStrip)
{
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
return fieldName[prefix.Length..];
}
return fieldName;
}
/// <summary>
/// A driver's status constant must carry the value the SDK gives the code it is named after.
/// </summary>
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
/// <param name="type">The declaring type's full name (failure-message context).</param>
/// <param name="field">The constant's name as declared.</param>
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
/// <param name="declared">The value the driver declares.</param>
[Theory]
[MemberData(nameof(DeclaredStatusConstants))]
public void Driver_status_constant_matches_the_pinned_SDK(
string assembly, string type, string field, string sdkName, uint declared)
{
SdkStatusCodes.ShouldContainKey(sdkName,
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
"not start with Good/Uncertain/Bad.");
var expected = SdkStatusCodes[sdkName];
declared.ShouldBe(expected,
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
$"is 0x{expected:X8}" +
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
: ". The declared value is not any OPC UA status code."));
}
/// <summary>
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
/// </summary>
[Fact]
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
{
var found = DeclaredStatusConstants();
found.Count.ShouldBeGreaterThan(20,
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
"or the naming convention changed");
var assemblies = found
.Select(row => row.Data.Item1)
.Distinct(StringComparer.Ordinal)
.ToArray();
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
}
@@ -12,6 +12,8 @@
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -34,6 +36,11 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
puts it in StatusCodeParityTests' scope. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,180 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
/// transition (which for a still-active alarm with static dependencies may never come).
/// </summary>
/// <remarks>
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
/// report the state, and reading it does not fire an emission.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmProjectionTests
{
private static ScriptedAlarmDefinition Alarm(
string id = "HighTemp",
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
string msg = "Temp is {Temp}",
AlarmSeverity sev = AlarmSeverity.High) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate);
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
private static AlarmConditionState PersistedActive(
string alarmId = "HighTemp",
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
AlarmEnabledState.Enabled,
AlarmActiveState.Active,
acked,
AlarmConfirmedState.Confirmed,
ShelvingState.Unshelved,
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
[Fact]
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
{
var up = new FakeUpstream();
up.Set("Temp", 150); // predicate still true — no transition to report
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
var emissions = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
lock (emissions)
{
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
}
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.AlarmId.ShouldBe("HighTemp");
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
}
/// <summary>The projection carries the definition's severity and the message template resolved against
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
[Fact]
public async Task Projection_carries_definition_severity_and_a_resolved_message()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Severity.ShouldBe(AlarmSeverity.Critical);
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
}
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
/// or every deploy would append a duplicate historian / alerts row.</summary>
[Fact]
public async Task Reading_projections_never_fires_an_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var emissions = 0;
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
Volatile.Read(ref emissions).ShouldBe(0);
}
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
/// "nothing to restore" from "restore this".</summary>
[Fact]
public async Task A_normal_alarm_projects_the_no_event_position()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false
using var eng = Build(up, new InMemoryAlarmStateStore());
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
}
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
[Fact]
public async Task Projection_carries_the_last_observed_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
var deadline = DateTime.UtcNow.AddSeconds(10);
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
{
await Task.Delay(20, TestContext.Current.CancellationToken);
}
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
}
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
[Fact]
public void Projections_are_empty_before_the_first_load()
{
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
eng.GetProjections().ShouldBeEmpty();
}
}
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
[InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC DA category byte.</param>
[Theory]
[InlineData(0x00000000u, (byte)192)] // Good
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x40000000u, (byte)64)] // Uncertain
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x80000000u, (byte)0)] // Bad
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
{
[Theory]
[InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
[InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
{
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value);
}
@@ -0,0 +1,204 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Records how many command executions are ever in flight at once, and how long each one is artificially
/// held open. Shared by <see cref="ConcurrencyProbingDbConnection"/> and the commands it creates.
/// </summary>
/// <remarks>
/// This exists because "the session serializes on a gate" is otherwise unfalsifiable from the outside:
/// against a real SQLite connection, overlapping calls mostly still *work*, so a test that only asserted
/// correct results would pass with the gate deleted. Measuring the overlap directly is what makes the
/// gate's removal visible.
/// </remarks>
public sealed class ConcurrencyProbe
{
private int _inFlight;
private int _peak;
/// <summary>How long each command execution is held before it reaches the real provider.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120);
/// <summary>The greatest number of executions observed in flight simultaneously.</summary>
public int Peak => Volatile.Read(ref _peak);
/// <summary>Marks the start of one command execution.</summary>
public void Enter()
{
var current = Interlocked.Increment(ref _inFlight);
int observed;
while (current > (observed = Volatile.Read(ref _peak)))
{
if (Interlocked.CompareExchange(ref _peak, current, observed) == observed) break;
}
}
/// <summary>Marks the end of one command execution.</summary>
public void Exit() => Interlocked.Decrement(ref _inFlight);
}
/// <summary>
/// A pass-through <see cref="DbConnection"/> that hands out commands instrumented with a
/// <see cref="ConcurrencyProbe"/>. Everything else delegates to the wrapped connection, so the code under
/// test runs against a real SQLite catalog.
/// </summary>
/// <param name="inner">The real connection to delegate to. Not owned — the caller disposes it.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString
{
get => inner.ConnectionString;
set => inner.ConnectionString = value!;
}
/// <inheritdoc />
public override string Database => inner.Database;
/// <inheritdoc />
public override string DataSource => inner.DataSource;
/// <inheritdoc />
public override string ServerVersion => inner.ServerVersion;
/// <inheritdoc />
public override ConnectionState State => inner.State;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
/// <inheritdoc />
public override void Close() => inner.Close();
/// <inheritdoc />
public override void Open() => inner.Open();
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
inner.BeginTransaction(isolationLevel);
/// <inheritdoc />
protected override DbCommand CreateDbCommand() =>
new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe);
}
/// <summary>
/// A pass-through <see cref="DbCommand"/> that reports every execution to a <see cref="ConcurrencyProbe"/>
/// and holds it open for the probe's delay, so overlapping executions are observable.
/// </summary>
/// <param name="inner">The real command to delegate to.</param>
/// <param name="owner">The connection that created this command.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe)
: DbCommand
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string CommandText
{
get => inner.CommandText;
set => inner.CommandText = value!;
}
/// <inheritdoc />
public override int CommandTimeout
{
get => inner.CommandTimeout;
set => inner.CommandTimeout = value;
}
/// <inheritdoc />
public override CommandType CommandType
{
get => inner.CommandType;
set => inner.CommandType = value;
}
/// <inheritdoc />
public override bool DesignTimeVisible
{
get => inner.DesignTimeVisible;
set => inner.DesignTimeVisible = value;
}
/// <inheritdoc />
public override UpdateRowSource UpdatedRowSource
{
get => inner.UpdatedRowSource;
set => inner.UpdatedRowSource = value;
}
/// <inheritdoc />
protected override DbConnection? DbConnection
{
get => owner;
set { /* The session never reassigns the connection; the wrapped command keeps its own. */ }
}
/// <inheritdoc />
protected override DbParameterCollection DbParameterCollection => inner.Parameters;
/// <inheritdoc />
protected override DbTransaction? DbTransaction
{
get => inner.Transaction;
set => inner.Transaction = value;
}
/// <inheritdoc />
public override void Cancel() => inner.Cancel();
/// <inheritdoc />
public override int ExecuteNonQuery() => inner.ExecuteNonQuery();
/// <inheritdoc />
public override object? ExecuteScalar() => inner.ExecuteScalar();
/// <inheritdoc />
public override void Prepare() => inner.Prepare();
/// <inheritdoc />
protected override DbParameter CreateDbParameter() => inner.CreateParameter();
/// <inheritdoc />
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
probe.Enter();
try
{
Thread.Sleep(probe.Delay);
return inner.ExecuteReader(behavior);
}
finally
{
probe.Exit();
}
}
/// <inheritdoc />
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
CommandBehavior behavior, CancellationToken cancellationToken)
{
probe.Enter();
try
{
await Task.Delay(probe.Delay, cancellationToken).ConfigureAwait(false);
return await inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
}
finally
{
probe.Exit();
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) inner.Dispose();
base.Dispose(disposing);
}
}
@@ -0,0 +1,128 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Pins the browse NodeId encoding. This codec is the load-bearing detail of the whole picker: a NodeId
/// that mis-parses sends the operator to a different column than the one they clicked, and the resulting
/// tag polls the wrong data forever with no error anywhere. SQL Server identifiers may legally contain
/// <c>.</c> and <c>|</c> (inside a quoted identifier), so the round-trip has to hold for those too.
/// </summary>
public sealed class SqlBrowseNodeIdTests
{
[Fact]
public void ForSchema_roundTrips()
{
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("dbo"));
reference.Kind.ShouldBe(SqlBrowseNodeKind.Schema);
reference.Schema.ShouldBe("dbo");
reference.Table.ShouldBeNull();
reference.Column.ShouldBeNull();
}
[Fact]
public void ForTable_roundTrips()
{
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("dbo", "TagValues"));
reference.Kind.ShouldBe(SqlBrowseNodeKind.Table);
reference.Schema.ShouldBe("dbo");
reference.Table.ShouldBe("TagValues");
reference.Column.ShouldBeNull();
}
[Fact]
public void ForColumn_roundTrips()
{
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn("dbo", "TagValues", "num_value"));
reference.Kind.ShouldBe(SqlBrowseNodeKind.Column);
reference.Schema.ShouldBe("dbo");
reference.Table.ShouldBe("TagValues");
reference.Column.ShouldBe("num_value");
}
/// <summary>
/// The exact case the plan's <c>schema.table|column</c> sketch cannot express: <c>main.a.b|c</c> reads
/// equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c> + table <c>b</c>.
/// </summary>
[Theory]
[InlineData("a.b", "c", "d")]
[InlineData("a", "b.c", "d")]
[InlineData("a", "b", "c.d")]
[InlineData("a|b", "c", "d")]
[InlineData("a", "b|c", "d")]
[InlineData("a", "b", "c|d")]
[InlineData(@"a\b", "c", "d")]
[InlineData("a", @"b\|c", "d")]
[InlineData("a", "b", @"c\\d")]
[InlineData("a.b|c", "d.e|f", "g.h|i")]
[InlineData("schema", "table", "column")]
[InlineData("x:y", "z:w", "q:r")]
public void AwkwardIdentifiers_roundTripExactly(string schema, string table, string column)
{
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn(schema, table, column));
reference.Kind.ShouldBe(SqlBrowseNodeKind.Column);
reference.Schema.ShouldBe(schema);
reference.Table.ShouldBe(table);
reference.Column.ShouldBe(column);
}
/// <summary>
/// Two genuinely different columns must never collide on one NodeId. Under the plan's literal
/// <c>schema.table|column</c> sketch both of these render as <c>a.b|c</c>.
/// </summary>
[Fact]
public void DistinctReferences_neverCollide()
{
var nested = SqlBrowseNodeId.ForColumn("a.b", "t", "c");
var flat = SqlBrowseNodeId.ForColumn("a", "b.t", "c");
nested.ShouldNotBe(flat);
SqlBrowseNodeId.Parse(nested).Schema.ShouldBe("a.b");
SqlBrowseNodeId.Parse(flat).Schema.ShouldBe("a");
}
/// <summary>A table NodeId and a schema NodeId are never confusable, whatever the names contain.</summary>
[Fact]
public void KindsAreDistinguishable()
{
SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("a|b")).Kind.ShouldBe(SqlBrowseNodeKind.Schema);
SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("a", "b")).Kind.ShouldBe(SqlBrowseNodeKind.Table);
SqlBrowseNodeId.ForSchema("a|b").ShouldNotBe(SqlBrowseNodeId.ForTable("a", "b"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("main")] // no kind prefix
[InlineData("bogus:main")] // unknown kind
[InlineData("table:main")] // table kind with only one part
[InlineData("schema:main|extra")] // schema kind with two parts
[InlineData("column:a|b")] // column kind with two parts
[InlineData("column:a|b|c|d")] // column kind with four parts
[InlineData("schema:")] // empty part
[InlineData("table:main|")] // empty trailing part
[InlineData(@"schema:main\")] // dangling escape
[InlineData(":main")] // empty kind
public void MalformedNodeIds_areRejected(string? nodeId)
{
SqlBrowseNodeId.TryParse(nodeId, out _).ShouldBeFalse();
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.Parse(nodeId));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void EmptyIdentifiers_areRejectedAtEncode(string? identifier)
{
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForSchema(identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForTable("main", identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!));
}
}
@@ -0,0 +1,374 @@
using System.Data;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
/// <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
/// that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
/// with a quoting helper bolted on.
/// </summary>
public sealed class SqlBrowseSessionTests
{
private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId;
[Fact]
public async Task RootAsync_yieldsSchemaFolders()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var roots = await session.RootAsync(CancellationToken.None);
roots.Count.ShouldBe(1);
roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema);
roots[0].NodeId.ShouldBe(SchemaNodeId);
roots[0].Kind.ShouldBe(BrowseNodeKind.Folder);
roots[0].HasChildrenHint.ShouldBeTrue();
}
[Fact]
public async Task ExpandSchema_yieldsTablesAndViewsAsFolders()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint);
children.ShouldContain(n =>
n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable));
// The view is listed beside the tables, and is labelled so the operator can tell them apart.
var view = children
.Where(n => n.NodeId ==
SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName))
.ShouldHaveSingleItem();
view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName);
view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName);
}
/// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
// Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --",
// so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too.
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn
&& c.Kind == BrowseNodeKind.Leaf
&& !c.HasChildrenHint);
var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None);
var attribute = attributes.ShouldHaveSingleItem();
attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
attribute.IsArray.ShouldBeFalse();
attribute.SecurityClass.ShouldBe("ViewOnly");
attribute.IsAlarm.ShouldBeFalse();
}
[Fact]
public async Task AttributesAsync_mapsTextColumnToString()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
}
/// <summary>
/// An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
/// <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
/// still render.
/// </summary>
[Fact]
public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable);
var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None);
columns.Count.ShouldBe(2);
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn);
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
}
/// <summary>
/// The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
/// <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
/// this is the case that proves the encoding, not just the codec unit test.
/// </summary>
[Fact]
public async Task AwkwardIdentifiers_surviveTheFullWalk()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
var awkward = tables
.Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable)
.ShouldHaveSingleItem();
var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None);
columns.Select(c => c.DisplayName)
.ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 },
ignoreOrder: true);
var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn);
var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId);
parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable);
parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn);
var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None);
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
/// <summary>
/// A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
/// instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
/// </summary>
[Fact]
public async Task HostileTableName_isBound_notConcatenated()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable);
var columns = await session.ExpandAsync(nodeId, CancellationToken.None);
// Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it
// before anything about the columns, or a splice that returns no rows reds on the wrong line and the
// test stops proving the payload was inert.
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn);
var columnNodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn);
var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None);
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32));
}
/// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
[Fact]
public async Task HostileSchemaName_isBound_notConcatenated()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --");
var tables = await session.ExpandAsync(nodeId, CancellationToken.None);
// SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing.
tables.ShouldBeEmpty();
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
}
/// <summary>
/// A table node whose table is gone (dropped between browse and expand) yields an empty column list.
/// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less
/// table, and an empty catalog answer is what both shapes look like from the browser's side.
/// </summary>
[Fact]
public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable);
(await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();
var columnNodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever");
(await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty();
}
/// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
[Fact]
public async Task ExpandColumn_yieldsEmpty()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
(await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();
}
/// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
[Fact]
public async Task AttributesAsync_onNonLeafNode_yieldsEmpty()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
(await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty();
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
(await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty();
}
/// <summary>
/// A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
/// message — the same contract the Galaxy and OPC UA client sessions have, and what
/// <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
/// <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
/// </summary>
[Theory]
[InlineData("")]
[InlineData("not-a-node-id")]
[InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format
[InlineData("column:a|b|c|d")]
public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId)
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var expand = await Should.ThrowAsync<ArgumentException>(
() => session.ExpandAsync(nodeId, CancellationToken.None));
expand.Message.ShouldNotBeNullOrWhiteSpace();
await Should.ThrowAsync<ArgumentException>(
() => session.AttributesAsync(nodeId, CancellationToken.None));
}
/// <summary>
/// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
/// Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
/// assertion would pass with the gate deleted.
/// </summary>
[Fact]
public async Task ConcurrentCalls_areSerializedByTheGate()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var probe = new ConcurrencyProbe();
var probing = new ConcurrencyProbingDbConnection(db.Connection, probe);
await using var session = new SqlBrowseSession(probing, new SqliteDialect());
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
var calls = new[]
{
session.RootAsync(CancellationToken.None),
session.ExpandAsync(SchemaNodeId, CancellationToken.None),
session.ExpandAsync(tableNodeId, CancellationToken.None),
session.ExpandAsync(tableNodeId, CancellationToken.None),
};
var results = await Task.WhenAll(calls);
probe.Peak.ShouldBe(1);
// TagValues has three columns — proves the serialized calls still returned real catalog rows.
results[2].Count.ShouldBe(3);
}
/// <summary>
/// Ownership: the session takes over the connection it is handed and closes it on dispose. The
/// AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
/// not close the connection, nothing would — every reaped picker would leak one.
/// </summary>
[Fact]
public async Task DisposeAsync_closesTheConnectionItWasGiven()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var connection = db.OpenNewConnection();
var session = new SqlBrowseSession(connection, new SqliteDialect());
connection.State.ShouldBe(ConnectionState.Open);
await session.DisposeAsync();
connection.State.ShouldBe(ConnectionState.Closed);
}
[Fact]
public async Task DisposeAsync_isIdempotent()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());
await session.DisposeAsync();
await Should.NotThrowAsync(async () => await session.DisposeAsync());
}
[Fact]
public async Task CallsAfterDispose_throwObjectDisposedException()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
() => session.RootAsync(CancellationToken.None));
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// The AdminUI bounds every call with a 20 s linked CTS
/// (<c>BrowserSessionService.PerCallTimeout</c>) — the session invents no timeout of its own, it just
/// has to honour the token it is handed, all the way into the ADO.NET call.
/// </summary>
[Fact]
public async Task CancelledToken_isHonoured()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => session.RootAsync(cts.Token));
await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
}
/// <summary>
/// <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
/// refreshed it would be evicted out from under an actively browsing operator.
/// </summary>
[Fact]
public async Task LastUsedUtc_advancesOnEverySuccessfulCall()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var atStart = session.LastUsedUtc;
await Task.Delay(15, TestContext.Current.CancellationToken);
await session.RootAsync(CancellationToken.None);
var afterRoot = session.LastUsedUtc;
afterRoot.ShouldBeGreaterThan(atStart);
await Task.Delay(15, TestContext.Current.CancellationToken);
await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
var afterExpand = session.LastUsedUtc;
afterExpand.ShouldBeGreaterThan(afterRoot);
await Task.Delay(15, TestContext.Current.CancellationToken);
var columnNodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
await session.AttributesAsync(columnNodeId, CancellationToken.None);
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
}
}
@@ -0,0 +1,384 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Covers <see cref="SqlDriverBrowser"/>'s open side: which of the two ways to name a database wins, what
/// an unresolvable reference tells the operator, which providers this build carries — and, above all,
/// that a connection string never escapes into a log line or an exception.
/// <para>The successful-open cases run through the linked <see cref="SqliteDialect"/> against the real
/// seeded <see cref="SqliteBrowseFixture"/>, injected via the browser's <c>dialectSelector</c>
/// constructor parameter. That parameter is the production extension point for a P2 provider, not a test
/// hatch — the same conclusion the driver author reached for <c>SqlDriver</c>'s <c>factory</c>
/// parameter.</para>
/// </summary>
public sealed class SqlDriverBrowserTests
{
/// <summary>
/// The credential every hygiene assertion hunts for. Distinctive enough that a substring match
/// anywhere in an exception or a log line is proof of a leak, not a coincidence.
/// </summary>
private const string Password = "Sup3rSecret-OtOpcUa-Probe";
/// <summary>
/// A host that cannot resolve, so the connect fails in DNS rather than on a timer. <c>.invalid</c> is
/// reserved by RFC 2606 and can never be registered.
/// </summary>
private const string DeadHost = "otopcua-no-such-host.invalid";
private static Func<SqlProvider, ISqlDialect?> SqliteSelector => static _ => new SqliteDialect();
// ---- identity ----
[Fact]
public void DriverType_isTheSqlDriversOwnInterimConstant()
{
// Deliberately asserted against SqlDriver.DriverTypeName rather than a literal: DriverTypeNames.Sql
// does not exist yet (adding it reddens DriverTypeNamesGuardTests until a factory is registered), so
// the driver's local constant is the single definition both sides must agree on.
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
[Fact]
public void DefaultDialectSelector_carriesSqlServerOnly()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
// ---- connection-string reference resolution ----
/// <summary>The plan's headline case: an unresolvable ref must name the exact variable to set.</summary>
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
/// <summary>
/// The default (production) path: no injected reader, a real process environment variable. Uses a
/// run-unique name and restores it in a <c>finally</c>, so a failure cannot leave it set for the rest
/// of the assembly — environment variables are process-global and every other test shares them.
/// </summary>
[Fact]
public async Task Open_refResolvedFromTheRealEnvironment_opensSession()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var reference = "OtOpcUaBrowseTest" + Guid.NewGuid().ToString("N");
var variable = SqlDriverBrowser.EnvironmentVariableFor(reference);
Environment.SetEnvironmentVariable(variable, db.ConnectionString);
try
{
var browser = new SqlDriverBrowser(SqliteSelector);
await using var session = await browser.OpenAsync(
ConfigJson(connectionStringRef: reference), CancellationToken.None);
var roots = await session.RootAsync(CancellationToken.None);
roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);
}
finally
{
Environment.SetEnvironmentVariable(variable, null);
}
}
[Fact]
public async Task Open_neitherRefNorLiteral_failsNamingBothWays()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(), CancellationToken.None));
ex.Message.ShouldContain("connectionStringRef");
ex.Message.ShouldContain("connectionString");
}
[Fact]
public async Task Open_blankRef_failsRatherThanResolvingAnEmptyVariableName()
{
var browser = new SqlDriverBrowser(SqliteSelector, static _ => "should never be read");
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- pasted literal, and its precedence ----
/// <summary>
/// A pasted literal opens a working session, and <b>the session owns the connection</b> — it is still
/// live after <c>OpenAsync</c> returns (the browser did not close it) and dead after the session is
/// disposed (nothing else will).
/// </summary>
[Fact]
public async Task Open_pastedLiteral_opensWorkingSessionThatOwnsTheConnection()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var browser = new SqlDriverBrowser(SqliteSelector);
var session = await browser.OpenAsync(
ConfigJson(connectionString: db.ConnectionString), CancellationToken.None);
var children = await session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None);
children.ShouldContain(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(() =>
session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// Precedence: the pasted literal wins, and the reference is <b>not even read</b>. Proven by a reader
/// that records every call — an assertion on the resulting session alone would still pass if the ref
/// were consulted first and merely lost a tie-break.
/// </summary>
[Fact]
public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var reads = new List<string>();
var logger = new CapturingLogger<SqlDriverBrowser>();
var browser = new SqlDriverBrowser(
SqliteSelector,
name =>
{
reads.Add(name);
return "Data Source=/otopcua-no-such-directory/never.db";
},
logger);
await using var session = await browser.OpenAsync(
ConfigJson(connectionStringRef: "MesStaging", connectionString: db.ConnectionString),
CancellationToken.None);
reads.ShouldBeEmpty();
var roots = await session.RootAsync(CancellationToken.None);
roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema);
// The override is announced, naming the shadowed ref — never any connection text.
logger.Entries.ShouldContain(e => e.Contains("MesStaging", StringComparison.Ordinal));
logger.Entries.ShouldNotContain(e => e.Contains(db.ConnectionString, StringComparison.OrdinalIgnoreCase));
}
// ---- provider availability ----
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// Provider availability is answered before the environment is touched: "this build cannot browse
/// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
// ---- malformed configuration ----
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Open_emptyConfig_failsAskingForOne(string configJson)
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(configJson, CancellationToken.None));
ex.Message.ShouldContain("configuration");
}
/// <summary>
/// Malformed JSON must fail as malformed JSON — and must not echo the offending text back, because
/// the text a JSON parser chokes on may itself be a half-pasted connection string.
/// </summary>
[Fact]
public async Task Open_malformedJson_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """;
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(configJson, CancellationToken.None));
ex.Message.ShouldContain("not valid JSON");
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
}
[Fact]
public async Task Open_jsonThatIsNotAnObject_fails()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("[1,2,3]", CancellationToken.None));
ex.Message.ShouldContain("JSON object");
}
/// <summary>An unknown <c>provider</c> name is a bind failure, and must not echo the config either.</summary>
[Fact]
public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("NotARealProvider", connectionString: $"Server=x;Password={Password}"),
CancellationToken.None));
ex.Message.ShouldContain("could not be bound");
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
}
// ---- credential hygiene ----
/// <summary>
/// The natural case the task names: a well-formed connection string carrying a password, pointed at a
/// host that does not exist. Nothing about the string may reach the exception.
/// </summary>
[Fact]
public async Task Open_unreachableHost_neverLeaksThePastedConnectionString()
{
var connectionString =
$"Server={DeadHost};Database=Mes;User ID=sa;Password={Password};Connect Timeout=1;"
+ "TrustServerCertificate=true";
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));
ex.ToString().ShouldNotContain(Password, Case.Insensitive);
ex.ToString().ShouldNotContain(connectionString, Case.Insensitive);
// Still says something useful about *why*.
ex.Message.ShouldContain("could not open a connection");
}
/// <summary>
/// The leak that is real, and is the reason the parser's message is suppressed rather than trusted.
/// <para>ADO.NET expects a value containing <c>;</c> to be quoted. An unquoted one splits, and the
/// tail of the password is then parsed as a keyword — which
/// <c>Microsoft.Data.SqlClient</c> echoes verbatim (lower-cased) in
/// <c>Keyword not supported: '…'</c>. The first half of this test is the control: it proves the raw
/// provider really does leak, so the second half asserting the browser does not is not vacuous.</para>
/// </summary>
[Fact]
public async Task Open_semicolonBearingPassword_neverLeaksTheProvidersEchoedKeyword()
{
const string tail = "OtOpcUaTailSecret";
var connectionString = $"Server={DeadHost};Password=Sup3r;{tail};Connect Timeout=1";
// Control: the bare provider leaks the tail of the password into its own exception.
var raw = Should.Throw<ArgumentException>(() =>
{
using var connection = new SqlServerDialect().Factory.CreateConnection()!;
connection.ConnectionString = connectionString;
});
raw.ToString().ShouldContain(tail, Case.Insensitive);
// The browser does not.
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));
ex.ToString().ShouldNotContain(tail, Case.Insensitive);
ex.Message.ShouldContain("malformed");
}
/// <summary>
/// Nothing the browser logs — on the success path or the failure path — carries connection text. The
/// ref name is fair game; the string never is.
/// </summary>
[Fact]
public async Task Open_neverLogsTheConnectionString()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var logger = new CapturingLogger<SqlDriverBrowser>();
var browser = new SqlDriverBrowser(SqliteSelector, static _ => null, logger);
await using (await browser.OpenAsync(
ConfigJson(connectionString: db.ConnectionString), CancellationToken.None))
{
// Opened purely for its log output.
}
var dead = $"Data Source=/otopcua-no-such-directory/never.db;Password={Password}";
await Should.ThrowAsync<Exception>(() =>
browser.OpenAsync(ConfigJson(connectionString: dead), CancellationToken.None));
logger.Entries.ShouldNotBeEmpty();
foreach (var entry in logger.Entries)
{
entry.ShouldNotContain(db.ConnectionString, Case.Insensitive);
entry.ShouldNotContain(Password, Case.Insensitive);
entry.ShouldNotContain("Data Source", Case.Insensitive);
}
}
// ---- helpers ----
/// <summary>
/// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing
/// JSON metacharacters (backslashes in a <c>Data Source</c> path, quotes in a password) escapes
/// correctly instead of producing malformed JSON the test would then misattribute.
/// </summary>
private static string ConfigJson(
string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null)
{
var map = new Dictionary<string, object?> { ["provider"] = provider };
if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef;
if (connectionString is not null) map["connectionString"] = connectionString;
return JsonSerializer.Serialize(map);
}
/// <summary>Records every formatted log message so the hygiene assertions can read them back.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Entries { get; } = [];
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(formatter(state, exception));
}
}
@@ -0,0 +1,190 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// A real, seeded SQLite database standing in for the SQL Server the schema browser walks. It exists so
/// the browse path runs against a genuine catalog — real <c>sqlite_schema</c> rows, real
/// <c>pragma_table_info</c> output, real parameter binding — with no SQL Server and no network.
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c></b>, for the same reason
/// <c>SqlitePollFixture</c> is: an in-memory database dies with its last connection, and several tests
/// here deliberately open a second connection or dispose the session's connection mid-test.</para>
/// <para><b>The seed is a contract.</b> Every table below exists to pin one browse behaviour, and the
/// awkward ones are the point: <see cref="AwkwardTable"/> and <see cref="AwkwardColumn"/> carry the
/// <c>.</c> and <c>|</c> characters that a naive <c>schema.table|column</c> NodeId would mis-parse, and
/// <see cref="HostileTable"/> is a live injection probe — if any catalog query concatenated a name
/// instead of binding it, expanding that table drops <see cref="KeyValueTable"/> and the assertions say
/// so.</para>
/// </summary>
public sealed class SqliteBrowseFixture : IAsyncDisposable
{
/// <summary>The ordinary key-value table, mirroring the poll fixture's shape.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>REAL</c>, i.e. <c>Float64</c>.</summary>
public const string RealColumn = "num_value";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>TEXT</c>, i.e. <c>String</c>.</summary>
public const string TextColumn = "tag_name";
/// <summary>A view over <see cref="KeyValueTable"/> — the browser must list views beside tables.</summary>
public const string ViewName = "TagValuesView";
/// <summary>
/// A table whose name contains BOTH NodeId metacharacters. A <c>schema.table|column</c> NodeId cannot
/// represent this unambiguously — <c>main.a.b|c</c> reads equally as schema <c>main.a</c>, table
/// <c>b</c>. Legal in SQL Server inside a quoted identifier, so the encoding must survive it.
/// </summary>
public const string AwkwardTable = "a.b|c";
/// <summary>A column name carrying the separator character.</summary>
public const string AwkwardColumn = "x|y";
/// <summary>A column name carrying a dot and the escape character.</summary>
public const string AwkwardColumn2 = @"d.o\t";
/// <summary>
/// A table named as a SQL injection payload. Nothing about it is special to the browser — that is
/// exactly the assertion: it is bound as a value, never spliced into a command text.
/// </summary>
public const string HostileTable = "'); DROP TABLE TagValues; --";
/// <summary>The single column of <see cref="HostileTable"/>.</summary>
public const string HostileColumn = "hostile_col";
/// <summary>A table whose columns carry types no dialect map recognises.</summary>
public const string ExoticTable = "Exotic";
/// <summary>An <see cref="ExoticTable"/> column declared with a type outside the map's vocabulary.</summary>
public const string ExoticColumn = "shape_col";
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
public const string NonExistentTable = "NoSuchTable";
private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
{
_databasePath = databasePath;
ConnectionString = connectionString;
Connection = connection;
}
/// <summary>The connection string over the temporary database file.</summary>
public string ConnectionString { get; }
/// <summary>
/// A long-lived open connection over the seeded database — what tests hand
/// <c>SqlBrowseSession</c>. Independent of any other connection over the same file.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>
/// The NodeId of SQLite's one and only schema, encoded exactly as
/// <c>SqlBrowseSession.RootAsync</c> emits it.
/// </summary>
public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema);
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
/// <returns>The ready fixture.</returns>
public static async Task<SqliteBrowseFixture> CreateAsync()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"otopcua-sql-browse-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString();
var connection = new SqliteConnection(connectionString);
await connection.OpenAsync().ConfigureAwait(false);
await SeedAsync(connection).ConfigureAwait(false);
return new SqliteBrowseFixture(databasePath, connectionString, connection);
}
/// <summary>Opens a brand-new connection over the same database.</summary>
/// <returns>An open connection the caller owns.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Counts rows in a table, on a connection of its own so a disposed session cannot affect it.</summary>
/// <param name="table">The (unquoted) table name to count.</param>
/// <returns>The row count, or <c>-1</c> when the table does not exist.</returns>
public long CountRows(string table)
{
using var connection = OpenNewConnection();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
try
{
return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture);
}
catch (SqliteException)
{
return -1;
}
}
/// <summary>Closes the fixture connection, clears the pool, and deletes the temporary file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await Connection.DisposeAsync().ConfigureAwait(false);
// Microsoft.Data.Sqlite pools per connection string; without this the file stays held open and the
// delete silently fails, leaking one file per test into the temp directory.
SqliteConnection.ClearAllPools();
try
{
if (File.Exists(_databasePath)) File.Delete(_databasePath);
}
catch (IOException)
{
// A leaked temp file must never fail a test run.
}
}
/// <summary>
/// Applies the schema described by this type's constants. Every column is explicitly declared —
/// SQLite stores affinity rather than a type, so an undeclared column would give
/// <c>pragma_table_info</c> (and therefore <c>MapColumnType</c>) nothing real to report.
/// </summary>
private static async Task SeedAsync(SqliteConnection connection)
{
await ExecuteAsync(connection, $"""
CREATE TABLE "{KeyValueTable}" (
"{TextColumn}" TEXT NOT NULL,
"{RealColumn}" REAL NULL,
sample_ts TEXT NOT NULL
);
INSERT INTO "{KeyValueTable}" ("{TextColumn}", "{RealColumn}", sample_ts)
VALUES ('Line1.Speed', 42.0, '2026-07-24T10:00:00Z');
CREATE VIEW "{ViewName}" AS SELECT "{TextColumn}", "{RealColumn}" FROM "{KeyValueTable}";
CREATE TABLE "{ExoticTable}" (
"{ExoticColumn}" GEOGRAPHY NULL,
blob_col BLOB NULL
);
""").ConfigureAwait(false);
// Separate statements: these names must be quoted by the seed itself, so keep them off the
// interpolation path above where a stray quote would be hard to spot.
await ExecuteAsync(connection,
$"CREATE TABLE {Quote(AwkwardTable)} ({Quote(AwkwardColumn)} REAL NULL, {Quote(AwkwardColumn2)} TEXT NULL)")
.ConfigureAwait(false);
await ExecuteAsync(connection,
$"CREATE TABLE {Quote(HostileTable)} ({Quote(HostileColumn)} INTEGER NULL)")
.ConfigureAwait(false);
}
private static string Quote(string identifier) =>
string.Concat("\"", identifier.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
private static async Task ExecuteAsync(SqliteConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!--
TEST-ONLY, same rationale as Driver.Sql.Tests: SQLite is the offline substrate that lets the
schema browser run against a real DbConnection (real parameter binding, real catalog rows) with
no SQL Server and no network. No product project references SQLite. The bundle_e_sqlite3 line is
the surgical direct pin that promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
<!--
LINKED, not duplicated. SqliteDialect is the only non-SQL-Server ISqlDialect in the tree and is
therefore the falsifiability control for the whole seam: it proves the browser runs the *dialect's*
catalog SQL rather than INFORMATION_SCHEMA with a quoting function attached. Linking the one file
(rather than referencing Driver.Sql.Tests) keeps a second copy from drifting while avoiding a
test-project-to-test-project reference. The file is public + self-contained for exactly this reason
— see its class docs.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
</ItemGroup>
</Project>
@@ -0,0 +1,68 @@
# Dedicated, DISPOSABLE SQL Server for the Sql driver's blackhole / frozen-peer live gate.
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN mssql container, `otopcua-sql-blackhole`, on host port
# 14333. The blackhole gate `docker pause`s THIS container by that hard-coded name.
# It is NOT — and must never be — the rig's shared always-on SQL Server, which is
# `10.100.0.35,14330` and hosts ConfigDb for the whole dev rig. Two independent
# guardrails keep them apart:
# 1. a distinct container_name (`otopcua-sql-blackhole`) — the pause target is a
# compile-time constant in the test, and the shared server is not named this; and
# 2. a distinct host port (14333, not 14330) — the test SKIPS LOUDLY if its
# endpoint resolves to port 14330.
# `restart: "no"` so a paused/killed container stays down rather than respawning.
#
# ── DEPLOY (from this VM; docker runs on the shared Linux host) ──────────────────
# lmxopcua-fix sync sql-blackhole # rsync this Docker/ dir → /opt/otopcua-sql-blackhole/
# lmxopcua-fix up sql-blackhole # single-service stack (+ one-shot seed), no profile arg
# `lmxopcua-fix` applies the host-side `project=lmxopcua` label; the explicit label
# below keeps the stack discoverable even when brought up with plain `docker compose`.
#
# The one-shot `mssql-seed` service applies seed.sql once the server is healthy, so
# the stack is self-seeding. The blackhole test ALSO seeds defensively on connect
# (create-if-missing, same schema), so a stack that never ran the seed still works.
name: otopcua-sql-blackhole
services:
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: otopcua-sql-blackhole
labels:
project: lmxopcua
restart: "no"
environment:
ACCEPT_EULA: "Y"
# Dev-only sandbox password for a throwaway container. Override via the shell env
# (the same value the test reads from SQL_BLACKHOLE_PASSWORD).
MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}"
MSSQL_PID: "Developer"
ports:
- "14333:1433"
healthcheck:
# -C trusts the self-signed dev cert; the login succeeding is the readiness signal.
test:
- "CMD-SHELL"
- "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -Q 'SELECT 1' || exit 1"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
# One-shot seed: waits for the server to pass its healthcheck, applies seed.sql, exits.
mssql-seed:
image: mcr.microsoft.com/mssql/server:2022-latest
labels:
project: lmxopcua
restart: "no"
depends_on:
mssql:
condition: service_healthy
environment:
MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}"
volumes:
- ./seed.sql:/seed.sql:ro
entrypoint:
- "/bin/bash"
- "-c"
- "/opt/mssql-tools18/bin/sqlcmd -C -S mssql -U sa -P \"$${MSSQL_SA_PASSWORD}\" -i /seed.sql"
@@ -0,0 +1,43 @@
-- Seed for the Sql driver's blackhole / frozen-peer live gate (dedicated container only).
--
-- Applied once by the compose `mssql-seed` one-shot against the OWNED
-- `otopcua-sql-blackhole` container. The blackhole test (SqlBlackholeTimeoutTests)
-- also creates this exact shape defensively on connect, so the two are kept in
-- parity: a Good baseline poll before the pause depends on `sqlpoll.TagValues`
-- holding the one seeded row.
--
-- Two sample tables mirror the offline SqlitePollFixture / live SqlPollServerFixture
-- shapes so the gate polls something representative, not a bespoke schema.
IF DB_ID(N'SqlBlackholeFixture') IS NULL CREATE DATABASE [SqlBlackholeFixture];
GO
USE [SqlBlackholeFixture];
GO
IF SCHEMA_ID(N'sqlpoll') IS NULL EXEC(N'CREATE SCHEMA [sqlpoll]');
GO
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
DROP TABLE IF EXISTS [sqlpoll].[TagValues];
CREATE TABLE [sqlpoll].[TagValues] (
[tag_name] nvarchar(128) NOT NULL,
[num_value] float NULL,
[sample_ts] datetime2(3) NOT NULL
);
INSERT INTO [sqlpoll].[TagValues] ([tag_name], [num_value], [sample_ts])
VALUES (N'Line1.Speed', 42.0, '2026-07-24T10:00:00'),
(N'Line1.Pressure', 3.5, '2026-07-24T10:00:01');
GO
-- Wide-row source: one row per station, several value columns.
DROP TABLE IF EXISTS [sqlpoll].[LatestStatus];
CREATE TABLE [sqlpoll].[LatestStatus] (
[station_id] int NOT NULL,
[oven_temp] float NULL,
[pressure] float NULL,
[sample_ts] datetime2(3) NOT NULL
);
INSERT INTO [sqlpoll].[LatestStatus] ([station_id], [oven_temp], [pressure], [sample_ts])
VALUES (7, 180.5, 1.2, '2026-07-24T10:00:00');
GO
@@ -0,0 +1,440 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The single highest-value integration test for the <c>Sql</c> driver: the <b>frozen-peer /
/// blackhole gate</b>. When the database stops answering mid-poll, a reused pooled connection hangs
/// inside <c>ExecuteReaderAsync</c>; the driver must surface <see cref="SqlStatusCodes.BadTimeout"/>
/// within its client-side <c>operationTimeout</c> (a real linked-CTS cancellation) and NOT wedge the
/// poll loop. This is the exact bug class the repo shipped and fixed once in its S7 driver (async
/// reads ignored the socket read timeout → a frozen peer wedged the poll loop; see
/// <c>S7_1500ConnectTimeoutOutageTests</c>, which this mirrors).
/// <para><b>The wall-clock bound is the whole point.</b> <c>operationTimeout</c> is set well BELOW
/// <c>commandTimeout</c> here, deliberately inverted from the production rule, so the two backstops
/// are distinguishable: a working client-side cancellation returns at ≈ <c>operationTimeout</c>; if it
/// were broken and only the ADO.NET <c>CommandTimeout</c> server-side backstop fired, the read would
/// return at ≈ <c>commandTimeout</c> instead — which the upper-bound assertion fails on. A test that
/// merely asserted "eventually BadTimeout" would pass even with the client-side cancellation broken.</para>
/// <para><b>SAFETY — this gate `docker pause`s a DEDICATED container it owns, never shared infra.</b>
/// The pause target is the compile-time constant <see cref="ContainerName"/>
/// (<c>otopcua-sql-blackhole</c>), never anything derived from an env var, so a mis-set endpoint can
/// never aim a pause at the rig's shared always-on SQL Server (<c>10.100.0.35,14330</c>, which hosts
/// ConfigDb for the whole rig). As a second guardrail the gate <b>skips loudly</b> if its endpoint
/// resolves to the shared server's port <see cref="SharedCentralPort"/>. Bring the dedicated stack up
/// with <c>Docker/docker-compose.yml</c> (container <c>otopcua-sql-blackhole</c>, host port 14333).</para>
/// <para><b>Env-gated; offline skip is the normal outcome.</b> With <see cref="EndpointEnvVar"/> unset
/// nothing here opens a socket, shells out to docker, or waits — the suite is instant and green on the
/// macOS dev box, exactly like every other <c>*.IntegrationTests</c> live gate in this tree.</para>
/// <para>
/// <b>Operator run recipe</b> (docker runs on the shared Linux host; drive it from this VM):
/// <code>
/// # 1. Deploy + start the DEDICATED disposable mssql (NOT the shared 14330 server):
/// lmxopcua-fix sync sql-blackhole
/// lmxopcua-fix up sql-blackhole # container otopcua-sql-blackhole on :14333, self-seeds
///
/// # 2. Point the gate at the dedicated container + tell it how to reach docker:
/// export SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 # the dedicated container; MUST NOT be ,14330
/// export SQL_BLACKHOLE_PASSWORD=Blackhole_dev_pw1 # matches the compose SA password
/// export SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 # empty ⇒ run docker locally
///
/// # 3. Run just this gate:
/// dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
/// --filter "FullyQualifiedName~SqlBlackholeTimeoutTests"
///
/// # 4. Tear the disposable stack down:
/// lmxopcua-fix down sql-blackhole
/// </code>
/// </para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "Blackhole")]
public sealed class SqlBlackholeTimeoutTests
{
/// <summary>Endpoint (<c>host,port</c>) of the DEDICATED disposable container. Absent ⇒ offline skip.</summary>
private const string EndpointEnvVar = "SQL_BLACKHOLE_ENDPOINT";
/// <summary>SA password for the dedicated container. Required by the run.</summary>
private const string PasswordEnvVar = "SQL_BLACKHOLE_PASSWORD";
/// <summary>SQL login. Defaults to <c>sa</c>.</summary>
private const string UserEnvVar = "SQL_BLACKHOLE_USER";
/// <summary>
/// Optional SSH target the <c>docker pause</c>/<c>unpause</c> runs through (e.g.
/// <c>dohertj2@10.100.0.35</c>), since <c>docker -H ssh://</c> does not work from this VM. Empty ⇒
/// run docker locally.
/// </summary>
private const string DockerSshEnvVar = "SQL_BLACKHOLE_DOCKER_SSH";
/// <summary>
/// The ONLY container this gate ever pauses — a compile-time constant, never derived from the
/// environment. The shared central SQL Server is not named this, so no env misconfiguration can
/// cause this gate to freeze shared infra. It matches <c>Docker/docker-compose.yml</c>'s
/// <c>container_name</c>.
/// </summary>
private const string ContainerName = "otopcua-sql-blackhole";
/// <summary>
/// The rig's shared always-on SQL Server port (hosts ConfigDb for the whole rig). An endpoint on
/// this port is refused with a loud skip — the dedicated container must be on a different port.
/// </summary>
private const string SharedCentralPort = "14330";
/// <summary>The database the seed lives in; created if missing (never dropped).</summary>
private const string Database = "SqlBlackholeFixture";
/// <summary>The seeded key-value table + its columns and the one baseline key.</summary>
private const string Schema = "sqlpoll";
private const string KeyValueTable = "TagValues";
private const string KeyColumn = "tag_name";
private const string ValueColumn = "num_value";
private const string TimestampColumn = "sample_ts";
private const string BaselineKey = "Line1.Speed";
private const double BaselineValue = 42.0;
/// <summary>The RawPath the driver serves the baseline key under.</summary>
private const string RawPath = "Sql/Line1/Speed";
// ── deadlines chosen so client-side and server-side backstops are DISTINGUISHABLE ──
/// <summary>Client-side wall-clock ceiling on one group. A frozen peer must surface BadTimeout at ≈ this.</summary>
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(3);
/// <summary>
/// ADO.NET server-side backstop — set an order of magnitude ABOVE <see cref="OperationTimeout"/> so
/// that if the client-side cancellation were broken and only this fired, the read would return at
/// ≈ 30 s and the upper-bound assertion would catch it.
/// </summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Upper bound on the post-pause read. Comfortably above <see cref="OperationTimeout"/> (docker
/// pause propagation + pooled-connection validation + thread-pool scheduling) yet comfortably below
/// <see cref="CommandTimeout"/> — so a return here proves the CLIENT-side bound fired, not the
/// server-side backstop.
/// </summary>
private static readonly TimeSpan UpperBound = TimeSpan.FromSeconds(15);
/// <summary>Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI.</summary>
private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60);
/// <summary>Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI.</summary>
private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30);
/// <summary>
/// Start reading against the dedicated mssql, <c>docker pause</c> it mid-poll, and assert the next
/// read surfaces <see cref="SqlStatusCodes.BadTimeout"/> within <see cref="OperationTimeout"/> (a
/// client-side linked-CTS cancellation, not the poll thread wedging and not the server-side
/// backstop), that the driver degrades (health <see cref="DriverState.Degraded"/> + host
/// <see cref="HostState.Stopped"/>), and that after <c>docker unpause</c> a subsequent poll recovers
/// to <see cref="DriverState.Healthy"/> / <see cref="HostState.Running"/>.
/// </summary>
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable(EndpointEnvVar);
Assert.SkipWhen(
string.IsNullOrWhiteSpace(ep),
$"{EndpointEnvVar} not set — offline skip. Bring up Docker/docker-compose.yml (dedicated " +
$"container {ContainerName} on :14333) and export {EndpointEnvVar}/{PasswordEnvVar} to run this gate.");
var password = Environment.GetEnvironmentVariable(PasswordEnvVar);
Assert.SkipWhen(
string.IsNullOrWhiteSpace(password),
$"{PasswordEnvVar} not set — the blackhole gate needs the dedicated container's SA password.");
var (host, port) = ParseEndpoint(ep!);
// ── SAFETY GUARD: refuse to run against the rig's shared central SQL Server. ──
// The pause target (ContainerName) is a constant and could never be the shared container regardless,
// but refusing the shared PORT stops us even connecting-and-polling against shared infra.
Assert.SkipWhen(
string.Equals(port, SharedCentralPort, StringComparison.Ordinal),
$"{EndpointEnvVar} resolves to port {SharedCentralPort} — that is the rig's SHARED always-on SQL " +
"Server (ConfigDb for the whole rig), which this destructive gate must NEVER pause. Point it at " +
$"the dedicated {ContainerName} container (host port 14333), not the shared server.");
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
var ssh = Environment.GetEnvironmentVariable(DockerSshEnvVar);
var ct = TestContext.Current.CancellationToken;
// Seed defensively (create-if-missing) so the baseline poll is Good even if compose never ran seed.sql.
await SeedAsync(host, port, user, password!, ct);
await using var driver = await StartDriverAsync(host, port, user, password!, ct);
// Good baseline — prove the endpoint answers before we freeze it.
var baseline = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem();
baseline.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(baseline.Value).ShouldBe(BaselineValue);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var paused = false;
try
{
// Freeze the container mid-poll: its SQL process is SIGSTOPped, so the pooled connection's next
// ExecuteReaderAsync hangs against a live-but-silent socket — the frozen-peer wedge exactly.
// Mark paused BEFORE the await: if `ct` fires mid-command the process may still complete the pause,
// so the finally must attempt an unpause regardless — never leave the container frozen.
paused = true;
await RunShellCommandAsync(DockerCommand("pause", ssh), ct);
var clock = Stopwatch.StartNew();
// Bounded by HardCap so a wedged implementation FAILS here instead of hanging CI.
var snapshots = await ReadOnceAsync(driver, ct).WaitAsync(HardCap, ct);
clock.Stop();
var frozen = snapshots.ShouldHaveSingleItem();
frozen.StatusCode.ShouldBe(
SqlStatusCodes.BadTimeout,
"a frozen database must surface BadTimeout, not a stale Good or another Bad class");
// The wall-clock contract: returned at ≈ operationTimeout (client-side cancellation), NOT at
// commandTimeout (server-side backstop) and NOT never. UpperBound < CommandTimeout is what
// distinguishes a working client-side abort from a broken one that only the backstop rescued.
clock.Elapsed.ShouldBeLessThan(
UpperBound,
$"BadTimeout must arrive on the client-side {OperationTimeout.TotalSeconds:0}s bound, not the " +
$"server-side {CommandTimeout.TotalSeconds:0}s CommandTimeout backstop");
clock.Elapsed.ShouldBeLessThan(CommandTimeout, "the server-side backstop must not be what fired");
clock.Elapsed.ShouldBeGreaterThanOrEqualTo(
OperationTimeout - TimeSpan.FromSeconds(2),
"returning far faster than operationTimeout would mean a fast-fail misclassified as BadTimeout");
// Driver-level classification, not just the reader: an all-BadTimeout poll degrades health and
// stops the host (SqlDriver.ObservePollOutcome), even though the reader returned successfully.
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Stopped);
}
finally
{
if (paused)
{
// ALWAYS restore the container once a pause was attempted — never leave a frozen container
// behind. Best-effort: unpausing a container that never actually paused (pause command failed)
// errors harmlessly, and a cleanup failure must never mask the real test outcome.
try
{
await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None);
}
catch
{
// swallow — cleanup is best-effort; the container is disposable and torn down by the runbook.
}
}
}
// Recovery: after unpause a subsequent poll must return Good and the whole stack must recover.
await PollUntilGoodAsync(driver, ct);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ── helpers ──
/// <summary>Runs one poll of the single baseline reference.</summary>
private static Task<IReadOnlyList<DataValueSnapshot>> ReadOnceAsync(SqlDriver driver, CancellationToken ct)
=> driver.ReadAsync([RawPath], ct);
/// <summary>Polls until a Good snapshot returns, or the recovery deadline elapses.</summary>
private static async Task PollUntilGoodAsync(SqlDriver driver, CancellationToken ct)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(45);
while (DateTime.UtcNow < deadline)
{
var snapshot = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem();
if (snapshot.StatusCode == SqlStatusCodes.Good) return;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
throw new Xunit.Sdk.XunitException(
"the driver never recovered to a Good poll within 45 s of docker unpause — the poll loop did not survive the outage");
}
/// <summary>Builds a driver over the dedicated container, warmed to hold a pooled connection.</summary>
private static async Task<SqlDriver> StartDriverAsync(
string host, string port, string user, string password, CancellationToken ct)
{
var options = new SqlDriverOptions
{
RawTags = [KeyValueTag()],
OperationTimeout = OperationTimeout,
CommandTimeout = CommandTimeout,
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-blackhole",
dialect: new SqlServerDialect(),
connectionString: DriverConnectionString(host, port, user, password));
try
{
await driver.InitializeAsync("{}", ct);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>The KeyValue tag over the seeded <c>sqlpoll.TagValues</c> baseline row.</summary>
private static RawTagEntry KeyValueTag()
{
var config = new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = $"{Schema}.{KeyValueTable}",
["keyColumn"] = KeyColumn,
["keyValue"] = BaselineKey,
["valueColumn"] = ValueColumn,
["timestampColumn"] = TimestampColumn,
};
return new RawTagEntry(RawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// The driver's connection string. <c>Min Pool Size=1</c> keeps a warm pooled connection so the
/// post-pause read reuses it and hangs inside <c>ExecuteReaderAsync</c> — exercising the
/// query-execution wedge (the S7 bug class) rather than a fresh connect.
/// </summary>
private static string DriverConnectionString(string host, string port, string user, string password)
=> new SqlConnectionStringBuilder
{
DataSource = $"{host},{port}",
InitialCatalog = Database,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
ConnectTimeout = 5,
MinPoolSize = 1,
}.ConnectionString;
/// <summary>Creates the database (if missing) and (re)seeds the baseline schema + row.</summary>
private static async Task SeedAsync(
string host, string port, string user, string password, CancellationToken ct)
{
var master = new SqlConnectionStringBuilder
{
DataSource = $"{host},{port}",
InitialCatalog = "master",
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
ConnectTimeout = 10,
}.ConnectionString;
await using (var connection = new SqlConnection(master))
{
await connection.OpenAsync(ct);
await ExecuteAsync(connection, $"IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];", ct);
}
var db = new SqlConnectionStringBuilder(master) { InitialCatalog = Database }.ConnectionString;
await using (var connection = new SqlConnection(db))
{
await connection.OpenAsync(ct);
foreach (var statement in SeedStatements())
await ExecuteAsync(connection, statement, ct);
}
}
/// <summary>The seed, one batch per statement (T-SQL requires CREATE SCHEMA to be first in its batch).</summary>
private static IEnumerable<string> SeedStatements()
{
yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
yield return $"""
CREATE TABLE [{Schema}].[{KeyValueTable}] (
[{KeyColumn}] nvarchar(128) NOT NULL,
[{ValueColumn}] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
VALUES (N'{BaselineKey}', {BaselineValue}, '2026-07-24T10:00:00');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
}
/// <summary>Splits a <c>host,port</c> endpoint. A missing port is rejected — the guard needs the port.</summary>
private static (string Host, string Port) ParseEndpoint(string endpoint)
{
var parts = endpoint.Split(',', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || parts[1].Length == 0)
{
throw new Xunit.Sdk.XunitException(
$"{EndpointEnvVar} must be 'host,port' (e.g. 10.100.0.35,14333) so the shared-server port " +
$"guard can run — got '{endpoint}'.");
}
return (parts[0], parts[1]);
}
/// <summary>
/// Builds the docker command for the OWNED container only. The verb and the constant container name
/// are the sole inputs — the target is never taken from the environment.
/// </summary>
private static string DockerCommand(string verb, string? ssh)
{
var docker = $"docker {verb} {ContainerName}";
return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\"";
}
/// <summary>
/// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded
/// by its own <see cref="ShellCommandHardCap"/> so a hung SSH handshake (unreachable docker host, an auth
/// prompt) fails the test rather than hanging CI indefinitely. The process is killed on timeout.
/// </summary>
private static async Task RunShellCommandAsync(string command, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ShellCommandHardCap);
using var proc = new Process
{
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
};
proc.Start();
try
{
await proc.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
{
try { proc.Kill(entireProcessTree: true); } catch { /* already gone */ }
throw new TimeoutException(
$"shell command did not complete within {ShellCommandHardCap.TotalSeconds:0}s: {command}");
}
proc.ExitCode.ShouldBe(0, $"docker command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
// Give the pause/unpause a moment to take effect before the next poll tick.
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}
@@ -0,0 +1,469 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// A real, seeded <b>SQL Server</b> database standing in for the source the <c>Sql</c> driver polls —
/// the live counterpart of the offline <c>SqlitePollFixture</c>, and the only place the shipped
/// <c>Microsoft.Data.SqlClient</c> path, the real <c>INFORMATION_SCHEMA</c> catalog SQL, and genuine
/// T-SQL column types are exercised at all.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="ConnectionStringEnvVar"/> — or <see cref="EndpointEnvVar"/> plus a
/// password — is set; absent, it records <see cref="SkipReason"/> and every test calls
/// <c>Assert.Skip</c>. That is what keeps <c>dotnet test</c> genuinely green (and instant) on the
/// macOS dev box this is usually run from, which is the same contract every other
/// <c>*.IntegrationTests</c> fixture in this tree honours.</para>
/// <para><b>Safety against the shared central server (design §9).</b> The always-on SQL Server on the
/// docker host hosts <c>ConfigDb</c> for the whole dev rig, so this fixture is deliberately
/// conservative:</para>
/// <list type="bullet">
/// <item>It uses <b>its own database</b>, <see cref="FixtureDatabase"/> — the supplied connection
/// string's catalog is used only to reach <c>master</c> and is otherwise <b>ignored</b>, so a
/// mis-set env var cannot aim the seed at somebody else's schema.</item>
/// <item>A supplied catalog literally named <c>ConfigDb</c> is rejected <b>loudly, at
/// construction</b>, rather than quietly retargeted — it is the signature of a copied-and-pasted
/// connection string, and the operator should know their env var is wrong.</item>
/// <item>It <b>creates the database only if missing</b> and <b>never drops a database</b>, so
/// re-running the suite is safe and an operator-created <c>SqlPollFixture</c> is never destroyed.
/// Only the objects inside its own <see cref="Schema"/> schema are dropped and recreated, which
/// is what makes the seed deterministic across runs.</item>
/// </list>
/// <para><b>The seed is a contract, not scaffolding.</b> The constants below mirror
/// <c>SqlitePollFixture</c>'s shape — a present value, a present row with a <b>NULL</b> cell, an
/// <b>absent</b> key, and a wide-row table whose newest row is deliberately not its first — so the
/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server
/// paths shows up as a failing assertion rather than as two suites that quietly test different
/// things. <see cref="TypeProbeTable"/> has no offline counterpart: it exists to pin
/// <c>SqlServerDialect.MapColumnType</c> against the type names SQL Server <em>actually</em> reports,
/// which a hand-written mapping table cannot do.</para>
/// </summary>
public sealed class SqlPollServerFixture : IAsyncLifetime
{
/// <summary>A full ADO.NET connection string for the fixture server. Takes precedence when set.</summary>
public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";
/// <summary>
/// Server endpoint in <c>host,port</c> form (e.g. <c>10.100.0.35,14330</c>) — the lighter-weight
/// gate. Requires <see cref="PasswordEnvVar"/>; <see cref="UserEnvVar"/> defaults to <c>sa</c>.
/// </summary>
public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";
/// <summary>SQL login for the <see cref="EndpointEnvVar"/> form. Defaults to <c>sa</c>.</summary>
public const string UserEnvVar = "SQL_TEST_USER";
/// <summary>SQL password for the <see cref="EndpointEnvVar"/> form. Required by that form.</summary>
public const string PasswordEnvVar = "SQL_TEST_PASSWORD";
/// <summary>The database this fixture creates and seeds. Never <c>ConfigDb</c>, never dropped.</summary>
public const string FixtureDatabase = "SqlPollFixture";
/// <summary>
/// The schema every seeded object lives in. Deliberately <b>not</b> <c>dbo</c>: it scopes the
/// drop-and-recreate to objects this fixture owns, and it makes every authored <c>table</c> a
/// two-part name, so the planner's <c>QuoteQualifiedName</c> (<c>[sqlpoll].[TagValues]</c>) is
/// exercised against a real server rather than only in a unit test.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>
/// The <c>Application Name</c> the driver's connections carry, so the pooling assertion can count
/// exactly this suite's sessions on a server shared with the whole dev rig.
/// </summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";
/// <summary>The <c>Application Name</c> the fixture's own inspection connections carry.</summary>
private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name nvarchar(128), num_value float NULL, sample_ts datetime2</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>A key whose row <b>exists</b> but whose value cell is <c>NULL</c>.</summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>A key with <b>no row at all</b> — a different outcome from <see cref="NullValueKey"/>.</summary>
public const string AbsentKey = "Line1.Missing";
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc =
new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2</c>.</summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that loses its
/// <c>ORDER BY … DESC</c> or its <c>SELECT TOP 1</c> picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — what a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
/// <summary>A view over <see cref="WideRowTable"/> — the <c>TABLE_TYPE = 'VIEW'</c> catalog row.</summary>
public const string WideRowView = "LatestStatusView";
// ---- T-SQL type-coverage source ----
/// <summary>
/// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
/// authored <c>type</c>, each column's driver type is inferred from what
/// <c>SqlDataReader.GetDataTypeName</c> reports — which is the only way to find out whether
/// <c>SqlServerDialect.MapColumnType</c>'s table matches reality rather than matching itself.
/// </summary>
public const string TypeProbeTable = "TypeProbe";
/// <summary>The single-row selector column of <see cref="TypeProbeTable"/>.</summary>
public const string TypeProbeSelectorColumn = "probe_id";
/// <summary>The seeded row's <see cref="TypeProbeSelectorColumn"/> value.</summary>
public const string TypeProbeRow = "1";
/// <summary>Seeded <c>bit</c> value.</summary>
public const bool ProbeBit = true;
/// <summary>Seeded <c>int</c> value — <c>int.MaxValue</c>, so a narrowing coercion overflows loudly.</summary>
public const int ProbeInt = int.MaxValue;
/// <summary>Seeded <c>bigint</c> value — beyond exact <c>double</c> range, so an Int64→Float64 slip shows.</summary>
public const long ProbeBigInt = 9007199254740993L;
/// <summary>Seeded <c>real</c> value; exactly representable, so equality is a fair assertion.</summary>
public const float ProbeReal = 1.5f;
/// <summary>Seeded <c>float</c> value; exactly representable.</summary>
public const double ProbeFloat = 3.25d;
/// <summary>Seeded <c>decimal(18,4)</c> value — the documented Float64 precision collapse.</summary>
public const decimal ProbeDecimal = 123.4567m;
/// <summary>Seeded <c>nvarchar(64)</c> value.</summary>
public const string ProbeNVarChar = "hello";
/// <summary>Seeded <c>datetime2(3)</c> value, read back as UTC.</summary>
public static readonly DateTime ProbeDateTimeUtc =
new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);
/// <summary>
/// Why the suite is skipping, or <see langword="null"/> when the fixture is seeded and usable.
/// Tests read this and call <c>Assert.Skip</c> — the house idiom
/// (<c>Snap7ServerFixture</c> / <c>ModbusSimulatorFixture</c>).
/// </summary>
public string? SkipReason { get; private set; }
/// <summary>
/// The connection string to hand the code under test: the seeded <see cref="FixtureDatabase"/>,
/// tagged with <see cref="DriverApplicationName"/>. Empty while <see cref="SkipReason"/> is set.
/// </summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Connection string the fixture's own inspection connections use. Empty while skipping.</summary>
private string _inspectionConnectionString = string.Empty;
/// <summary>
/// Resolves the gate, creates <see cref="FixtureDatabase"/> if missing, and (re)seeds this
/// fixture's schema.
/// <para>The two failure modes are handled deliberately differently: <b>failing to connect</b> is
/// the offline case and becomes a <see cref="SkipReason"/>, while <b>failing after the connection
/// opened</b> (DDL, seeding) is a real fault against a reachable server and is allowed to throw —
/// swallowing that would leave the suite silently testing an unseeded database.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } supplied)
{
SkipReason =
$"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " +
$"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " +
"the Sql driver's live SQL Server suite. No connection is attempted without it.";
return;
}
var builder = new SqlConnectionStringBuilder(supplied);
GuardAgainstConfigDb(builder);
if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds;
Server = builder.DataSource;
// The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase.
var master = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = "master",
ApplicationName = FixtureApplicationName,
}.ConnectionString;
SqlConnection connection;
try
{
connection = new SqlConnection(master);
await connection.OpenAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
SkipReason =
$"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " +
$"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite.";
return;
}
await using (connection.ConfigureAwait(false))
{
// Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever
// made it, keeps everything outside this fixture's own schema.
await ExecuteAsync(
connection,
$"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];")
.ConfigureAwait(false);
}
ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = DriverApplicationName,
}.ConnectionString;
_inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = FixtureApplicationName,
}.ConnectionString;
await SeedAsync().ConfigureAwait(false);
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged
/// with the fixture's own application name so it is never counted by the pooling assertion.
/// </summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public async Task<SqlConnection> OpenInspectionConnectionAsync()
{
var connection = new SqlConnection(_inspectionConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
return connection;
}
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Reads the connection-string gate. Returns <see langword="null"/> — touching no socket — when
/// neither form is configured.
/// </summary>
private static string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full)
return full;
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint)
return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login against a shared server, which is how accounts get locked out.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password)
return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
return new SqlConnectionStringBuilder
{
DataSource = endpoint,
InitialCatalog = FixtureDatabase,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
}.ConnectionString;
}
/// <summary>
/// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
/// <c>ConfigDb</c>. The catalog is ignored either way (everything lands in
/// <see cref="FixtureDatabase"/>), so this exists purely to tell an operator their env var is
/// wrong rather than to prevent damage that could otherwise occur.
/// </summary>
/// <exception cref="InvalidOperationException">The supplied catalog is named <c>ConfigDb</c>.</exception>
private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder)
{
if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return;
throw new InvalidOperationException(
$"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " +
$"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " +
"never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog).");
}
/// <summary>
/// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
/// <para>Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract:
/// a leftover row from an older revision of this file would make a passing test meaningless. The
/// blast radius is bounded to the <see cref="Schema"/> schema, which nothing else creates.</para>
/// </summary>
private async Task SeedAsync()
{
var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
// Views first: a view over a dropped table blocks nothing, but dropping the table under a live
// view leaves the catalog reporting a column set that no longer resolves.
foreach (var statement in SeedStatements())
await ExecuteAsync(connection, statement).ConfigureAwait(false);
}
}
/// <summary>
/// The seed, one batch per statement. <c>CREATE SCHEMA</c> and <c>CREATE VIEW</c> must each be the
/// first statement of their batch in T-SQL, which is why this is a list rather than one script.
/// <para>Every numeric literal is composed under <see cref="CultureInfo.InvariantCulture"/>: plain
/// interpolation would emit <c>180,5</c> on a comma-decimal machine, and T-SQL would read that as
/// two column values rather than as one wrong number — a seed failure with a baffling message.</para>
/// </summary>
private static IEnumerable<string> SeedStatements()
{
yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];";
yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";
yield return $"""
CREATE TABLE [{Schema}].[{KeyValueTable}] (
[{KeyColumn}] nvarchar(128) NOT NULL,
[{ValueColumn}] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'),
(N'{NullValueKey}', NULL, '2026-07-24T10:00:01'),
(N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE TABLE [{Schema}].[{WideRowTable}] (
[{WideRowSelectorColumn}] int NOT NULL,
[oven_temp] float NULL,
[pressure] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
// NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A
// topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two
// orderings are kept deliberately different.
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{WideRowTable}]
([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}])
VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE VIEW [{Schema}].[{WideRowView}] AS
SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]
FROM [{Schema}].[{WideRowTable}];
""";
yield return $"""
CREATE TABLE [{Schema}].[{TypeProbeTable}] (
[{TypeProbeSelectorColumn}] int NOT NULL,
[bit_col] bit NULL,
[int_col] int NULL,
[bigint_col] bigint NULL,
[real_col] real NULL,
[float_col] float NULL,
[decimal_col] decimal(18,4) NULL,
[nvarchar_col] nvarchar(64) NULL,
[datetime2_col] datetime2(3) NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{TypeProbeTable}]
([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col],
[decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}])
VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat},
{ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Collection definition so the seed runs once per test session rather than once per test class —
/// the same shape as <c>Snap7ServerCollection</c>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture<SqlPollServerFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPollServer";
}
@@ -0,0 +1,764 @@
using System.Data;
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real SQL Server</b>, over the <c>Microsoft.Data.SqlClient</c>
/// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which
/// means three things have never been exercised anywhere until this suite: the shipped ADO.NET
/// provider, T-SQL's own syntax (bracket-quoted two-part names, <c>SELECT TOP 1</c>), and the
/// <c>INFORMATION_SCHEMA</c> catalog SQL that <c>SqlServerDialect</c> carries but that no offline test
/// can reach — the SQLite dialect answers those questions with <c>pragma_table_info</c> instead.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="SqlPollServerFixture"/> for
/// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline —
/// which is the point: a live test that goes red on a laptop teaches people to ignore red.</para>
/// <para><b>Out of scope, deliberately:</b> the frozen-database / <c>BadTimeout</c> gate. That needs a
/// <c>docker pause</c>-able container this suite must never own, because the server it points at is
/// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql
/// container.</para>
/// </summary>
[Collection(SqlPollServerCollection.Name)]
public sealed class SqlServerReadTests
{
private readonly SqlPollServerFixture _sql;
/// <summary>Initializes a new instance of the <see cref="SqlServerReadTests"/> class.</summary>
/// <param name="sql">The seeded live-server fixture (collection-scoped).</param>
public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql;
// ---- lifecycle ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c> over a real connection) succeeds and the driver
/// reports Healthy with the endpoint described credential-free.
/// </summary>
[Fact]
public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. It must name the
// database and must not carry the password that reached the provider.
driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase);
driver.Endpoint.ShouldNotContain("Password");
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue);
// datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it
// through the host's zone, which is the behaviour this asserts against a real column type.
snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc);
}
/// <summary>
/// Two keys on the same table fold into ONE query (<c>… WHERE [tag_name] IN (@k0, @k1)</c>) and are
/// sliced back to the right tags. Against a real server this also proves the parameter binding and
/// the key-column stringification survive a genuine <c>nvarchar</c> key.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
{
SkipUnlessLive();
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey));
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue);
}
/// <summary>
/// A present row whose value cell is a real T-SQL <c>NULL</c> is Uncertain, not Bad and not
/// BadNoData — the row WAS read.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey));
var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain);
snapshot.Value.ShouldBeNull();
// The row exists, so its timestamp is still readable — that is exactly what distinguishes this
// from the absent-key case below.
snapshot.SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary><c>nullIsBad</c> re-codes the same NULL cell as Bad, and only that cell.</summary>
[Fact]
public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet()
{
SkipUnlessLive();
await using var driver = await StartAsync(
nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey));
var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad);
}
/// <summary>A key the table has no row for is BadNoData with no source timestamp.</summary>
[Fact]
public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey));
var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshot.SourceTimestampUtc.ShouldBeNull();
}
// ---- wide-row model ----
/// <summary>
/// Two columns of one wide row, selected by a <c>whereColumn/whereValue</c> pair, come back from a
/// single query — including the bound selector value coercing from authored text to a real
/// <c>int</c> column server-side.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(
WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation),
WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation),
WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation));
var snapshots = await driver.ReadAsync(
["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"],
TestContext.Current.CancellationToken);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure);
// A selector matching no row is BadNoData, not an error — and it does not disturb its siblings.
snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
}
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the T-SQL-specific leg.</b> The planner emits the row limit through the
/// dialect's <c>SingleRowLimitPrefix</c> — <c>SELECT TOP 1 …</c> for T-SQL, where every offline
/// test exercises SQLite's trailing <c>LIMIT 1</c> instead. The seed makes the newest row not the
/// first-inserted one, so losing either the <c>TOP 1</c> or the <c>ORDER BY … DESC</c> yields a
/// different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp);
}
/// <summary>A view reads exactly like the table it wraps — the shape operators are told to author.</summary>
[Fact]
public async Task ReadAsync_realSqlServer_readsThroughAView()
{
SkipUnlessLive();
await using var driver = await StartAsync(
WideRowWhere(
"Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation,
table: SqlPollServerFixture.WideRowView));
var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp);
}
// ---- type mapping against real T-SQL column types ----
/// <summary>
/// With no authored <c>type</c>, each tag's driver type is inferred from the provider's own
/// <c>GetDataTypeName</c> through <c>SqlServerDialect.MapColumnType</c>. This is the only test in
/// the estate where that map is checked against the strings SQL Server <em>actually</em> returns —
/// everywhere else it is fed a hand-written list, i.e. checked against itself.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes()
{
SkipUnlessLive();
await using var driver = await StartAsync(
TypeProbe("Sql/Probe/Bit", "bit_col"),
TypeProbe("Sql/Probe/Int", "int_col"),
TypeProbe("Sql/Probe/BigInt", "bigint_col"),
TypeProbe("Sql/Probe/Real", "real_col"),
TypeProbe("Sql/Probe/Float", "float_col"),
TypeProbe("Sql/Probe/Decimal", "decimal_col"),
TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"),
TypeProbe("Sql/Probe/DateTime2", "datetime2_col"));
var snapshots = await driver.ReadAsync(
[
"Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real",
"Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2",
],
TestContext.Current.CancellationToken);
foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
// bit → Boolean, not "1"; the CLR type is what the address space declared for the node.
snapshots[0].Value.ShouldBeOfType<bool>().ShouldBe(SqlPollServerFixture.ProbeBit);
snapshots[1].Value.ShouldBeOfType<int>().ShouldBe(SqlPollServerFixture.ProbeInt);
// bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to
// Float64 would come back as a different number rather than as a type-only difference.
snapshots[2].Value.ShouldBeOfType<long>().ShouldBe(SqlPollServerFixture.ProbeBigInt);
snapshots[3].Value.ShouldBeOfType<float>().ShouldBe(SqlPollServerFixture.ProbeReal);
snapshots[4].Value.ShouldBeOfType<double>().ShouldBe(SqlPollServerFixture.ProbeFloat);
// decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future
// change to that policy cannot land silently.
snapshots[5].Value.ShouldBeOfType<double>()
.ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9);
snapshots[6].Value.ShouldBeOfType<string>().ShouldBe(SqlPollServerFixture.ProbeNVarChar);
snapshots[7].Value.ShouldBeOfType<DateTime>().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc);
}
/// <summary>
/// An authored <c>type</c> wins over the inferred column type, and the coercion runs over the
/// provider's real CLR cell rather than over a test double.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType()
{
SkipUnlessLive();
await using var driver = await StartAsync(
TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String),
TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32),
TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64));
var snapshots = await driver.ReadAsync(
["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"],
TestContext.Current.CancellationToken);
foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[0].Value.ShouldBeOfType<string>()
.ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture));
snapshots[1].Value.ShouldBeOfType<int>().ShouldBe(1);
snapshots[2].Value.ShouldBeOfType<double>().ShouldBe(SqlPollServerFixture.ProbeReal);
}
/// <summary>
/// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone,
/// never a thrown read. <c>int.MaxValue</c> into an <c>Int16</c> is a genuine provider-level
/// <c>OverflowException</c>, not a synthesised one.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings()
{
SkipUnlessLive();
await using var driver = await StartAsync(
TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16),
TypeProbe("Sql/Probe/Float", "float_col"));
var snapshots = await driver.ReadAsync(
["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch);
snapshots[0].Value.ShouldBeNull();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
}
// ---- cancellation + connection lifecycle ----
/// <summary>
/// A cancelled poll propagates <see cref="OperationCanceledException"/> rather than publishing
/// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The
/// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate.
/// <para><b>What this proves, precisely.</b> The token is <em>already</em> cancelled before
/// <c>ReadAsync</c> is called, so the cancellation is observed at the reader's first checkpoint —
/// <c>SemaphoreSlim.WaitAsync(_, token)</c> — <b>before any <c>SqlConnection</c> opens</b>. It
/// therefore does <b>not</b> exercise <c>Microsoft.Data.SqlClient</c>'s in-flight cancellation of a
/// running command; that is left to the blackhole gate's deadline path
/// (<c>SqlBlackholeTimeoutTests</c>). What it pins is the driver-shell contract that a cancelled
/// token surfaces as an <see cref="OperationCanceledException"/> and is <b>never</b> swallowed into
/// a Bad snapshot or misread as an unreachable-database verdict — an offline-equivalent guarantee,
/// run here over the real provider only to keep it beside its siblings.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
// ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and
// which of the two arrives is not a contract this driver makes.
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token));
// Cancellation is teardown, not a database verdict: health must be untouched.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET
/// pools them. After many polls the server should still hold a <b>small, non-zero</b> number of
/// sessions for this driver's <c>Application Name</c>: non-zero proves the disposed connections
/// went back to a pool instead of being torn down, and small proves the poll loop is not leaking
/// one per pass. Counting per application name is what makes this safe on a server shared with the
/// whole dev rig.
/// </summary>
[Fact]
public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections()
{
SkipUnlessLive();
const int polls = 25;
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey));
for (var i = 0; i < polls; i++)
{
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
}
var sessions = await CountDriverSessionsAsync();
sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side");
sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25");
}
// ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ----
/// <summary>The dialect's schema list — real <c>INFORMATION_SCHEMA.TABLES</c> — sees the seeded schema.</summary>
[Fact]
public async Task Catalog_listSchemasSql_returnsTheSeededSchema()
{
SkipUnlessLive();
var schemas = await QueryCatalogAsync(
new SqlServerDialect().ListSchemasSql,
_ => { },
reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA")));
schemas.ShouldContain(SqlPollServerFixture.Schema);
}
/// <summary>
/// The dialect's table list returns the seeded relations for the bound schema, and flags the view
/// as <c>VIEW</c> — the <c>TABLE_TYPE</c> the browse session decorates its label from.
/// </summary>
[Fact]
public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView()
{
SkipUnlessLive();
var rows = await QueryCatalogAsync(
new SqlServerDialect().ListTablesSql,
command => Bind(command, "@schema", SqlPollServerFixture.Schema),
reader => (
Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")),
Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE"))));
var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal);
byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE");
byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE");
byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE");
byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW");
}
/// <summary>
/// The dialect's column list returns the key-value table's columns <b>in ordinal order</b> (the
/// order the browse tree renders), with <c>DATA_TYPE</c> values the dialect's map recognises.
/// </summary>
[Fact]
public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder()
{
SkipUnlessLive();
var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable);
columns.Select(c => c.Name).ToArray().ShouldBe(new[]
{
SqlPollServerFixture.KeyColumn,
SqlPollServerFixture.ValueColumn,
SqlPollServerFixture.TimestampColumn,
});
var dialect = new SqlServerDialect();
dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar
dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float
dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2
// IS_NULLABLE is the third projected column and the browse reads it; prove it is really there.
columns[0].IsNullable.ShouldBe("NO");
columns[1].IsNullable.ShouldBe("YES");
}
/// <summary>
/// Every T-SQL family the type-probe table declares maps to the driver type
/// <c>SqlServerDialect.MapColumnType</c> promises — read from the catalog's own
/// <c>DATA_TYPE</c> strings, which is the exact input the browse side-panel feeds it.
/// </summary>
[Fact]
public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies()
{
SkipUnlessLive();
var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable);
var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal);
var dialect = new SqlServerDialect();
dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean);
dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32);
dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64);
dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32);
dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64);
dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64);
dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String);
dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime);
}
// ---- helpers ----
// ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ----
/// <summary>
/// The gate's live proof: an authored column that does not exist is refused by the allow-list at
/// Initialize, so it never reaches a query — and its neighbour on the same table keeps reading.
/// </summary>
/// <remarks>
/// Only a real SQL Server exercises the real <c>SELECT SCHEMA_NAME()</c> + <c>INFORMATION_SCHEMA</c>
/// path the loader depends on; the SQLite-backed suites prove the logic but not that T-SQL's catalog
/// answers the shape the loader expects.
/// </remarks>
[Fact]
public async Task CatalogGate_realSqlServer_rejectsAnUnknownColumn_andSparesItsNeighbour()
{
SkipUnlessLive();
var bogus = Tag("Sql/Line1/Bogus", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
["keyColumn"] = SqlPollServerFixture.KeyColumn,
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = "no_such_column",
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
});
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), bogus);
// An authoring typo is not a database fault: the driver stays Healthy.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Bogus"], TestContext.Current.CancellationToken);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>
/// T-SQL object names are case-insensitive under the default collation, so a case-variant tag has
/// always been valid and must keep working — the gate substitutes the catalog's spelling rather than
/// rejecting the operator's.
/// </summary>
[Fact]
public async Task CatalogGate_realSqlServer_acceptsACaseVariantIdentifierAndStillReads()
{
SkipUnlessLive();
var upper = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable).ToUpperInvariant(),
["keyColumn"] = SqlPollServerFixture.KeyColumn.ToUpperInvariant(),
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = SqlPollServerFixture.ValueColumn.ToUpperInvariant(),
["timestampColumn"] = SqlPollServerFixture.TimestampColumn.ToUpperInvariant(),
});
await using var driver = await StartAsync(upper);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
}
/// <summary>
/// A cross-database name cannot be validated from this connection's catalog, so it is refused rather
/// than quoted into a query — the documented v1 limitation, asserted so it stays deliberate.
/// </summary>
[Fact]
public async Task CatalogGate_realSqlServer_rejectsAThreePartName()
{
SkipUnlessLive();
var threePart = Tag("Sql/Line1/Remote", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = $"otherdb.{SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable)}",
["keyColumn"] = SqlPollServerFixture.KeyColumn,
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = SqlPollServerFixture.ValueColumn,
});
await using var driver = await StartAsync(threePart);
(await driver.ReadAsync(["Sql/Line1/Remote"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database.
/// <para><b>There is no <c>ForProduction</c> / <c>ForTest</c> hatch on <see cref="SqlDriver"/></b>
/// — its single public constructor takes the resolved connection string, the dialect and an
/// optional factory, and leaving the factory unset is precisely what makes this suite exercise
/// <c>SqlClientFactory.Instance</c>, the provider the driver ships.</para>
/// </summary>
private Task<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags);
/// <inheritdoc cref="StartAsync(RawTagEntry[])"/>
private async Task<SqlDriver> StartAsync(bool nullIsBad, params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
NullIsBad = nullIsBad,
CommandTimeout = TimeSpan.FromSeconds(10),
OperationTimeout = TimeSpan.FromSeconds(15),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-integration",
dialect: new SqlServerDialect(),
connectionString: _sql.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
["keyColumn"] = SqlPollServerFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = SqlPollServerFixture.ValueColumn,
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by a <c>whereColumn</c>/<c>whereValue</c> pair.</summary>
private static RawTagEntry WideRowWhere(
string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn,
["whereValue"] = station,
},
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = SqlPollServerFixture.TimestampColumn,
},
});
/// <summary>A wide-row tag over the single-row type-probe table, optionally with a declared type.</summary>
private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null)
{
var config = new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable),
["columnName"] = column,
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn,
["whereValue"] = SqlPollServerFixture.TypeProbeRow,
},
};
// Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole
// point of the inference test; present ⇒ it overrides.
if (declared is not null) config["type"] = declared.Value.ToString();
return Tag(rawPath, config);
}
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry.
/// <para>Composed as a <see cref="JsonObject"/> rather than as an interpolated string literal on
/// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace
/// produces a config the parser silently rejects — which surfaces as <c>BadNodeIdUnknown</c>, i.e.
/// as a plausible-looking test failure about the driver rather than about the test.</para>
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
/// <summary>Runs one of the dialect's catalog statements against the seeded database.</summary>
private async Task<List<T>> QueryCatalogAsync<T>(
string sql, Action<DbCommand> bind, Func<DbDataReader, T> project)
{
var connection = await _sql.OpenInspectionConnectionAsync();
await using (connection.ConfigureAwait(false))
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
bind(command);
var results = new List<T>();
var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
await using (reader.ConfigureAwait(false))
{
while (await reader.ReadAsync(TestContext.Current.CancellationToken))
results.Add(project(reader));
}
return results;
}
}
}
/// <summary>Reads one seeded table's catalog columns through the dialect's <c>ListColumnsSql</c>.</summary>
private Task<List<(string Name, string DataType, string IsNullable)>> ReadCatalogColumnsAsync(string table) =>
QueryCatalogAsync(
new SqlServerDialect().ListColumnsSql,
command =>
{
Bind(command, "@schema", SqlPollServerFixture.Schema);
Bind(command, "@table", table);
},
reader => (
Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")),
DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")),
IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE"))));
/// <summary>
/// Counts the server-side sessions carrying the driver's application name. Skips — rather than
/// failing — when the configured login cannot read the DMV, since <c>VIEW SERVER STATE</c> is a
/// property of the credentials the operator supplied and not of the code under test.
/// </summary>
private async Task<int> CountDriverSessionsAsync()
{
try
{
var counts = await QueryCatalogAsync(
"SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app",
command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName),
reader => reader.GetInt32(reader.GetOrdinal("n")));
return counts[0];
}
catch (SqlException ex)
{
Assert.Skip(
"The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " +
$"pooling cannot be observed from here: {ex.Message}");
throw; // unreachable; Assert.Skip throws.
}
}
/// <summary>Binds one string catalog parameter, exactly as the browse session does.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise the real Microsoft.Data.SqlClient path — the analyzer's documented intentional case
("move the suppression into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,354 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
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 design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
/// of rejecting every tag.</para>
/// </summary>
public sealed class SqlCatalogGateDriverTests
{
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
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();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
/// turn a diagnosable Bad quality into a missing address-space entry.
/// </summary>
[Fact]
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The driver is healthy: an authoring typo is not a database fault.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// The node is still materialized...
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
/// query that failed only once the connection was open; now it never reaches a query at all.
/// </summary>
[Fact]
public async Task A_hostile_table_name_never_reaches_a_query()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Evil"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The fixture's table is untouched — proven by a second tag still reading through it.
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
}
/// <summary>
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
/// field and the reason.
/// </summary>
[Fact]
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var warning = logger.Entries
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ShouldHaveSingleItem();
warning.ShouldContain("Bogus");
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
warning.ShouldContain("num_valeu");
}
/// <summary>
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
/// catalog strings rather than operator input.
/// </summary>
[Fact]
public async Task A_case_variant_identifier_is_accepted_and_polls()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry(
"Speed", SqlitePollFixture.PresentKey,
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
/// be a round-trip that can only fail.
/// </summary>
[Fact]
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
/// </summary>
[Fact]
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT this is not valid sql"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
var thrown = await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
// The operator surface names the stage that actually failed — "reached it but could not read the
// catalog" and "could not reach it" send an operator to different systems.
thrown.Message.ShouldContain("catalog");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
/// every tag for an authoring fault the operator does not have.
/// </summary>
[Fact]
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
// ---- helpers ----
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
/// catalog-load failure modes can be driven against an otherwise-real dialect.
/// </summary>
/// <remarks>
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
/// would silently not happen and both tests below would pass for the wrong reason.
/// </remarks>
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public SqlProvider Provider => Inner.Provider;
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string ListSchemasSql { get; } = listSchemasSql;
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, new CapturingLogger(), rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
fixture.ConnectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
private static RawTagEntry KvEntry(
string rawPath,
string keyValue,
string? table = null,
string? valueColumn = null)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Records every log record, level + rendered message.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,316 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built
/// <see cref="SqlCatalog"/>. The end-to-end behaviour against a real catalog — and the proof that a
/// rejected tag keeps its node and publishes <c>BadNodeIdUnknown</c> — lives in
/// <see cref="SqlCatalogGateDriverTests"/>.
/// </summary>
public sealed class SqlCatalogGateTests
{
private static readonly SqliteDialect Dialect = new();
/// <summary>A catalog with one schema, two relations, and deliberately mixed-case column spellings.</summary>
private static SqlCatalog Catalog(string defaultSchema = "dbo") => new(
defaultSchema,
["dbo", "mes"],
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo"] = ["TagValues", "LatestStatus"],
["mes"] = ["Orders"],
},
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"],
["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"],
["mes.Orders"] = ["order_id", "qty"],
});
private static SqlTagDefinition KeyValueTag(
string table = "dbo.TagValues",
string keyColumn = "tag_name",
string valueColumn = "num_value",
string? timestampColumn = "sample_ts") =>
new("plant/sql/Speed", SqlTagModel.KeyValue, table,
KeyColumn: keyColumn, KeyValue: "Line1.Speed",
ValueColumn: valueColumn, TimestampColumn: timestampColumn);
private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) =>
SqlCatalogGate.Apply(tags, Catalog(), Dialect);
[Fact]
public void A_fully_resolvable_tag_is_accepted()
{
var result = Apply(KeyValueTag());
result.Rejected.ShouldBeEmpty();
result.Accepted.Count.ShouldBe(1);
}
/// <summary>
/// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an
/// operator typed. Authoring every identifier in the wrong case proves the substitution actually
/// happens rather than the input merely being waved through.
/// </summary>
[Fact]
public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling()
{
var authored = KeyValueTag(
table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS");
var accepted = Apply(authored).Accepted.ShouldHaveSingleItem();
accepted.Table.ShouldBe("dbo.TagValues");
accepted.KeyColumn.ShouldBe("tag_name");
accepted.ValueColumn.ShouldBe("num_value");
accepted.TimestampColumn.ShouldBe("sample_ts");
// Identity and bound VALUES are untouched — the gate rewrites identifiers only.
accepted.Name.ShouldBe(authored.Name);
accepted.KeyValue.ShouldBe(authored.KeyValue);
}
[Fact]
public void An_unqualified_table_resolves_in_the_default_schema()
{
var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem();
accepted.Table.ShouldBe("dbo.TagValues");
}
/// <summary>
/// Guessing <c>dbo</c> would be a silent lie on an estate that maps service accounts to their own
/// default schema, so the gate must resolve an unqualified name in whatever schema the server reports.
/// </summary>
[Fact]
public void An_unqualified_table_follows_a_non_dbo_default_schema()
{
var catalog = Catalog(defaultSchema: "mes");
var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect);
result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders");
}
[Fact]
public void An_unknown_table_rejects_the_tag()
{
var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem();
rejection.RawPath.ShouldBe("plant/sql/Speed");
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("NoSuchTable");
}
[Fact]
public void An_unknown_schema_rejects_the_tag()
{
var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("nope");
}
[Fact]
public void An_unknown_column_rejects_the_tag_and_names_the_field()
{
var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
rejection.Reason.ShouldContain("no_such_column");
rejection.Reason.ShouldContain("dbo.TagValues");
}
/// <summary>
/// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would
/// allow-list a column set from a relation the query will never read.
/// </summary>
[Fact]
public void A_table_from_another_schema_does_not_resolve_unqualified()
{
var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null))
.Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
}
/// <summary>
/// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it
/// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead.
/// </summary>
[Fact]
public void A_three_part_name_is_rejected_with_an_actionable_message()
{
var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("3-part");
rejection.Reason.ShouldContain("view");
}
/// <summary>
/// The injection shape the whole gate exists for: a hostile identifier must be refused by the
/// allow-list, not merely quoted into a query against a nonexistent object.
/// </summary>
[Theory]
[InlineData("TagValues]; DROP TABLE TagValues--")]
[InlineData("'; DROP TABLE TagValues--")]
[InlineData("TagValues WHERE 1=1 OR 1=1")]
public void A_hostile_table_name_is_rejected_by_the_allow_list(string table)
{
Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.Table));
}
[Theory]
[InlineData("num_value]; DROP TABLE TagValues--")]
[InlineData("(SELECT password FROM users)")]
public void A_hostile_column_name_is_rejected_by_the_allow_list(string column)
{
Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
}
/// <summary>
/// A name carrying a control or Unicode format character cannot be safely rendered into a log line
/// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it <em>without echoing it</em> — the
/// charset check runs before the catalog lookup precisely so no such string can reach a message.
/// </summary>
/// <remarks>
/// Driven against <see cref="SqlServerDialect"/>, not the test-only <see cref="SqliteDialect"/>: the
/// charset rules belong to the dialect (the gate delegates to
/// <see cref="ISqlDialect.QuoteIdentifier"/> rather than duplicating them), and SQLite's rules
/// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's
/// dialect — the first draft of this test asserted them against SQLite's and failed for that reason.
/// </remarks>
[Theory]
[InlineData("num\u0000value")] // Cc — embedded NUL
[InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement
[InlineData("num\u202Evalue")] // Cf — right-to-left override
[InlineData("num\u200Bvalue")] // Cf — zero-width space
public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column)
{
var result = SqlCatalogGate.Apply(
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
var rejection = result.Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
rejection.Reason.ShouldContain("withheld");
rejection.Reason.ShouldNotContain(column);
}
/// <summary>
/// An over-long name cannot name a real object and is likewise withheld, rather than pasting an
/// unbounded slab of operator input into a log line.
/// </summary>
[Fact]
public void An_over_long_identifier_is_rejected_without_being_echoed()
{
var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1);
var result = SqlCatalogGate.Apply(
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld");
}
/// <summary>
/// The complement, and the reason the charset check is not simply "withhold everything": a name that
/// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote.
/// </summary>
[Fact]
public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable()
{
var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem();
rejection.Reason.ShouldContain("num_valeu");
rejection.Reason.ShouldNotContain("withheld");
}
/// <summary>
/// On a case-sensitive collation a relation may legitimately carry both <c>Value</c> and <c>value</c>.
/// Picking one would publish a different column's data under the operator's node, so the only safe
/// answer is to refuse — but an EXACT match must still win, or a valid config would break.
/// </summary>
[Fact]
public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins()
{
var catalog = new SqlCatalog(
"dbo",
["dbo"],
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["T"] },
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo.T"] = ["k", "Value", "value"],
});
var ambiguous = new SqlTagDefinition(
"p/Ambiguous", SqlTagModel.KeyValue, "dbo.T",
KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE");
SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" };
SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem()
.ValueColumn.ShouldBe("value");
}
/// <summary>
/// One operator typo must not stop the other tags on the same database — the same rule the tag-table
/// build already follows for a malformed blob.
/// </summary>
[Fact]
public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it()
{
var good = KeyValueTag() with { Name = "plant/sql/Good" };
var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" };
var result = Apply(good, bad);
result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good");
result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad");
}
/// <summary>Every identifier-bearing field is checked, not just the two the key-value model happens to use.</summary>
[Fact]
public void The_wide_row_models_identifier_fields_are_validated_too()
{
var selectorTypo = new SqlTagDefinition(
"p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7");
Apply(selectorTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn));
var orderTypo = new SqlTagDefinition(
"p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts");
Apply(orderTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp));
var columnTypo = new SqlTagDefinition(
"p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7");
Apply(columnTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ColumnName));
var ok = new SqlTagDefinition(
"p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7");
var accepted = Apply(ok).Accepted.ShouldHaveSingleItem();
accepted.ColumnName.ShouldBe("oven_temp");
accepted.RowSelectorColumn.ShouldBe("station_id");
accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized
}
/// <summary>An absent optional identifier is not a rejection — only a present, unresolvable one is.</summary>
[Fact]
public void An_absent_optional_timestamp_column_is_left_alone()
{
var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem();
accepted.TimestampColumn.ShouldBeNull();
}
}
@@ -0,0 +1,452 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Proves the composition layer: <see cref="SqlDriverFactoryExtensions"/> (config blob → live driver) and
/// <see cref="SqlConnectionStringResolver"/> (a <c>connectionStringRef</c> name → the secret it stands for).
/// <para>Three things here are behaviour rather than plumbing, and each has its own test below.
/// <b>(1) The string-enum guard</b> — the driver-page enum-serialization defect that bit the AdminUI shipped
/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the
/// driver at deploy time. The factory's options therefore both accept a numeric <c>provider</c> and write the
/// NAME. <b>(2) Config validation</b> — <c>operationTimeout &gt; commandTimeout</c> is deliberately
/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a
/// deployment silently masks its own server-side backstop. <b>(3) Credential hygiene</b> — the resolved
/// connection string is a secret; it must not reach a log line or an exception message, and the tests below
/// assert that rather than trusting the reviewer.</para>
/// </summary>
public sealed class SqlDriverFactoryTests
{
// ---- the plan's headline legs ----
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
// ---- the other half of the enum guard: a NUMERIC provider must still parse ----
[Fact]
public void CreateInstance_providerWrittenAsANumber_stillParses()
{
// The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that
// would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the
// options accept both spellings on the way IN, and only ever emit the name on the way OUT.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null);
driver.DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
[Fact]
public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal()
{
// A config blob authored against a newer (or older) schema must not brick the driver.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null);
driver.DriverInstanceId.ShouldBe("d1");
}
// ---- connection-string resolution ----
[Fact]
public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable()
{
var name = NewRef();
var ex = Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
// The operator's next action is "set this variable", so the message must name it exactly.
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
}
[Fact]
public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent()
{
// An empty value is a half-provisioned deployment, not a connection string — failing here beats
// handing "" to the provider and reporting an unintelligible ADO.NET error.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), " ");
Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
}
[Fact]
public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef()
{
var name = NewRef();
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
}
// ---- provider gate ----
[Fact]
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("not available in this build");
}
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
[Fact]
public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected()
{
// Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout
// backstop can never be reached — the deployment silently loses one of its two independent bounds.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
""",
null));
ex.Message.ShouldContain("operationTimeout");
ex.Message.ShouldContain("commandTimeout");
}
[Fact]
public void CreateInstance_equalTimeouts_areAlsoRejected()
{
// "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect
// with a coin toss on top.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"}
""",
null));
}
[Theory]
[InlineData("\"commandTimeout\":\"00:00:00\"")]
[InlineData("\"operationTimeout\":\"-00:00:05\"")]
[InlineData("\"defaultPollInterval\":\"00:00:00\"")]
[InlineData("\"maxConcurrentGroups\":0")]
public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null));
}
[Fact]
public void CreateInstance_theDocumentedDefaults_passValidation()
{
// The falsifiability control for the four rejection legs above: an all-defaults config must build.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var options = SqlDriverFactoryExtensions.BuildOptions(
new SqlDriverConfigDto { ConnectionStringRef = name }, "d1");
options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10));
options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15));
options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
options.MaxConcurrentGroups.ShouldBe(4);
options.NullIsBad.ShouldBeFalse();
options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout);
}
// ---- rawTags: how the deploy artifact actually delivers a driver's tags ----
[Fact]
public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions()
{
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
"""
{
"connectionStringRef":"anything",
"rawTags":[
{"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false},
{"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}
]
}
""",
SqlDriverFactoryExtensions.JsonOptionsForTest)!;
var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1");
options.RawTags.Count.ShouldBe(2);
options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed");
options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp");
}
[Fact]
public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull()
{
var options = SqlDriverFactoryExtensions.BuildOptions(
new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1");
options.RawTags.ShouldBeEmpty();
}
// ---- v1 is read-only, structurally ----
[Fact]
public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly()
{
// allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible
// and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure
// mode is an operator who believes writes are enabled — which only a loud warning closes.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
driver.ShouldNotBeAssignableTo<IWritable>();
var warning = loggerFactory.Entries
.Where(e => e.Level >= LogLevel.Warning)
.ShouldHaveSingleItem();
warning.Message.ShouldContain("allowWrites");
warning.Message.ShouldContain("read-only");
}
[Fact]
public void CreateInstance_withoutAllowWrites_logsNoWarning()
{
// The falsifiability control for the warning above — a warning that always fires proves nothing.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory);
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
// ...and the factory DID log — so "no warning" is a statement about severity, not about silence.
loggerFactory.Entries.ShouldNotBeEmpty();
}
// ---- credential hygiene ----
[Fact]
public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint()
{
const string secret = "SuperSecret-PleaseDoNotLogMe";
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name),
$"Server=srv;Database=db;User ID=sa;Password={secret}");
var loggerFactory = new RecordingLoggerFactory();
var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
// The credential-free rendering IS emitted — the factory logs where the driver points, which is what
// makes "no connection string" a real constraint rather than "log nothing and call it safe".
driver.Endpoint.ShouldBe("srv/db");
foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret);
loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into.
}
[Fact]
public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString()
{
const string secret = "SuperSecret-PleaseDoNotThrowMe";
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name),
$"Server=srv;Database=db;Password={secret}");
// Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the
// secret in hand — the case where a careless $"...{connectionString}" would actually leak.
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
""",
null));
ex.ToString().ShouldNotContain(secret);
}
// ---- registry wiring ----
[Fact]
public void Register_registersTheFactoryUnderTheDriversTypeName()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var registry = new DriverFactoryRegistry();
SqlDriverFactoryExtensions.Register(registry);
var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull();
var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}""");
driver.ShouldBeOfType<SqlDriver>();
driver.DriverType.ShouldBe("Sql");
}
[Fact]
public void DriverTypeName_matchesTheDriversOwnConstant()
// Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart.
=> SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName);
// ---- argument guards ----
[Theory]
[InlineData("")]
[InlineData(" ")]
public void CreateInstance_blankInputs_areRejected(string blank)
{
Should.Throw<ArgumentException>(() =>
SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null));
Should.Throw<ArgumentException>(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null));
}
[Fact]
public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance()
{
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null));
ex.Message.ShouldContain("d1");
}
/// <summary>A per-test connection-string ref, so no two tests can collide over one process-wide env var.</summary>
private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}";
}
/// <summary>
/// Sets one environment variable for the life of the scope and restores its previous value — including
/// restoring it to <see langword="null"/>. Environment variables are process-global, so a test that sets
/// one without restoring it leaks into every test that runs after it in the same process.
/// </summary>
internal sealed class EnvironmentVariableScope : IDisposable
{
private readonly string _name;
private readonly string? _previous;
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/>, remembering what was there.</summary>
/// <param name="name">The environment variable to set.</param>
/// <param name="value">The value to set it to.</param>
public EnvironmentVariableScope(string name, string? value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
/// <summary>Restores the previous value.</summary>
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
/// <summary>
/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted
/// rather than assumed.
/// </summary>
internal sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly List<LogEntry> _entries = [];
/// <summary>The log entries recorded so far, in order.</summary>
public IReadOnlyList<LogEntry> Entries
{
get { lock (_entries) return [.. _entries]; }
}
/// <inheritdoc/>
public ILogger CreateLogger(string categoryName) => new Recorder(this);
/// <inheritdoc/>
public void AddProvider(ILoggerProvider provider) { }
/// <inheritdoc/>
public void Dispose() { }
private void Record(LogLevel level, string message)
{
lock (_entries) _entries.Add(new LogEntry(level, message));
}
/// <summary>One captured log entry.</summary>
/// <param name="Level">The level it was logged at.</param>
/// <param name="Message">The formatted message.</param>
internal sealed record LogEntry(LogLevel Level, string Message);
private sealed class Recorder(RecordingLoggerFactory owner) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> owner.Record(logLevel, formatter(state, exception));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
@@ -0,0 +1,198 @@
using System.Data;
using System.Data.Common;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Proves <see cref="SqlDriverProbe"/> — the AdminUI "Test Connect" liveness check — against the real
/// SQLite fixture. A probe opens a connection, runs the dialect's <c>SELECT 1</c>, and reports green with
/// latency or red with a reason; it <b>never throws</b> (the <see cref="IDriverProbe"/> contract).
/// <para>The probe parses the SAME factory DTO shape as <see cref="SqlDriverFactoryExtensions"/> (R2-11
/// factory parity, mirroring <c>ModbusDriverProbe</c>), so a config that Tests-green and a config that
/// Deploys are the same config. It resolves the <c>connectionStringRef</c> the same way too — and the
/// tests below assert the resolved connection string never reaches the red-result message.</para>
/// </summary>
public sealed class SqlDriverProbeTests
{
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
using var fixture = new SqlitePollFixture();
var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());
var result = await probe.ProbeAsync(
ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Latency.ShouldNotBeNull();
result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
}
[Fact]
public async Task Probe_declaresTheSqlDriverType()
=> SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect())
.DriverType.ShouldBe(SqlDriver.DriverTypeName);
[Fact]
public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing()
{
// A connection string that resolves fine but points at nothing openable. The probe must catch the
// provider's failure and hand back a red result, never propagate it.
var probe = SqlDriverProbe.ForTest(
new FailingFactory("simulated open failure"), new SqliteDialect());
var result = await probe.ProbeAsync(
ConfigJson(connectionString: "Data Source=/no/such/place.db"),
TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNullOrWhiteSpace();
result.Latency.ShouldBeNull();
}
[Fact]
public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow()
{
var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect());
var result = await probe.ProbeAsync(
"""{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("connectionStringRef");
}
[Fact]
public async Task Probe_configThatIsNotJson_returnsRed_notAThrow()
{
var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect());
var result = await probe.ProbeAsync(
"definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy()
{
// R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically
// serialised provider Test-Connects exactly as it Deploys.
using var fixture = new SqlitePollFixture();
var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());
var result = await probe.ProbeAsync(
ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeTrue();
}
[Fact]
public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage()
{
const string secret = "Password=DoNotLeakMeIntoAProbeMessage";
var connectionString = $"Data Source=/no/such.db;{secret}";
// The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its
// OWN exception message. This fake reproduces that — it throws with the connection string in the
// message — so a probe that surfaced ex.Message would leak, and this test would then go red.
var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect());
var result = await probe.ProbeAsync(
ConfigJson(connectionString: connectionString),
TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage");
}
[Fact]
public async Task Probe_honoursCancellation_asRed_notAThrow()
{
// A cancelled probe is still a probe result, not an exception the AdminUI has to catch.
using var fixture = new SqlitePollFixture();
var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync();
var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token);
result.Ok.ShouldBeFalse();
}
// ---- helpers ----
/// <summary>A config blob whose connectionStringRef resolves (for the life of the returned scope) to the
/// fixture's real database, so the probe's SELECT 1 actually runs.</summary>
private static string ConfigJson(SqlitePollFixture fixture)
=> ConfigJson(connectionString: fixture.ConnectionString);
private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture)
{
var name = SetRef(fixture.ConnectionString);
return $$"""{"provider":0,"connectionStringRef":"{{name}}"}""";
}
/// <summary>
/// Builds a config JSON whose <c>connectionStringRef</c> is provisioned to
/// <paramref name="connectionString"/> via the process environment — the exact resolution path the
/// factory uses. The env var is set for the test process; each call mints a unique ref so parallel
/// tests cannot collide, and the value is never unset (a harmless per-run leak of a random name).
/// </summary>
private static string ConfigJson(string connectionString)
{
var name = SetRef(connectionString);
return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}""";
}
private static string SetRef(string connectionString)
{
var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString);
return name;
}
/// <summary>A <see cref="DbProviderFactory"/> whose connections throw on open — a database that cannot be
/// reached, without needing a real unreachable endpoint.</summary>
private sealed class FailingFactory(string message) : DbProviderFactory
{
public override DbConnection CreateConnection() => new FailingConnection(message);
}
/// <summary>Like <see cref="FailingFactory"/>, but its open failure embeds the connection string in the
/// thrown exception's message — the realistic provider shape that a naive <c>ex.Message</c> would leak.</summary>
private sealed class LeakyFailingFactory : DbProviderFactory
{
public override DbConnection CreateConnection() => new FailingConnection(message: null);
}
private sealed class FailingConnection(string? message) : DbConnection
{
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => string.Empty;
public override string DataSource => string.Empty;
public override string ServerVersion => string.Empty;
public override ConnectionState State => ConnectionState.Closed;
// A null ctor message means "embed the connection string" — the leaky provider shape.
private string FailureMessage => message ?? $"connect failed for {ConnectionString}";
public override void Open() => throw new InvalidOperationException(FailureMessage);
public override Task OpenAsync(CancellationToken cancellationToken)
=> throw new InvalidOperationException(FailureMessage);
public override void Close() { }
public override void ChangeDatabase(string databaseName) { }
protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
}
}
@@ -0,0 +1,696 @@
using System.Data;
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
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>
/// Proves the <see cref="SqlDriver"/> shell — the part <see cref="SqlPollReader"/> deliberately does not
/// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery,
/// the poll-engine subscription overlay, and the health + host-connectivity surface.
/// <para><b>The health assertions are the point of this class.</b> The reader returns Bad-coded snapshots
/// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades
/// health on its own: a frozen database yields all-<c>BadTimeout</c> snapshots and a perfectly successful
/// <see cref="PollGroupEngine"/> tick. If the shell does not classify what came back, the driver reports
/// <see cref="DriverState.Healthy"/> while every value is Bad — the failure mode these tests exist to
/// prevent, together with its inverse (a tag typo must NOT report the database down).</para>
/// </summary>
public sealed class SqlDriverTests
{
private const string DriverInstanceId = "sql-1";
// ---- discovery + initialize ----
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.DriverType.ShouldBe("Sql");
driver.DriverInstanceId.ShouldBe(DriverInstanceId);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.ShouldContain(v =>
v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
// v1 is read-only by construction, not by configuration.
driver.ShouldNotBeAssignableTo<IWritable>();
}
[Fact]
public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture,
logger,
KvEntry("Speed", SqlitePollFixture.PresentKey),
new RawTagEntry("Broken", "not json at all", WriteIdempotent: false));
// A single malformed blob must never fail the whole driver.
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
capture.Variables[0].Info.FullName.ShouldBe("Speed");
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken"));
// …and the skipped tag resolves to nothing rather than to someone else's row.
var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
// Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in
// Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not
// seal as a silently-connected driver.
var thrown = await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
// Actionable: it names the endpoint the operator has to go look at…
health.LastError!.ShouldContain(NoSuchDatabaseName);
thrown.Message.ShouldContain(NoSuchDatabaseName);
// …and never the credential-bearing connection string.
health.LastError.ShouldNotContain(SecretToken);
thrown.Message.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
}
[Fact]
public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain()
{
using var fixture = new SqlitePollFixture();
// SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection
// string is unreachable until the directory exists, and reachable the moment it does.
var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(directory, "late.db"),
}.ToString();
await using var driver = NewDriver(
fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
try
{
Directory.CreateDirectory(directory);
await driver.ReinitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
// The authored table survived the round trip — a recovered driver still serves its tags.
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Count.ShouldBe(1);
}
finally
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
}
}
// ---- IReadable delegation ----
[Fact]
public async Task ReadAsync_delegatesToTheReader_valueForValue()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Temp", SqlitePollFixture.NullValueKey),
KvEntry("Missing", SqlitePollFixture.AbsentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var reference = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: rawPath => rawPath switch
{
"Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey),
"Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey),
"Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey),
_ => null,
});
string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"];
var throughDriver = await driver.ReadAsync(refs, CancellationToken.None);
var throughReader = await reference.ReadAsync(refs, CancellationToken.None);
throughDriver.Count.ShouldBe(throughReader.Count);
for (var i = 0; i < throughDriver.Count; i++)
{
throughDriver[i].Value.ShouldBe(throughReader[i].Value);
throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode);
throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc);
}
}
// ---- the sustained-timeout decision ----
[Fact]
public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, fixture.ConnectionString, CapturingLogger.Null,
// Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while
// the server-side backstop would still be waiting — the reader's frozen-peer shape.
operationTimeout: TimeSpan.FromMilliseconds(500),
commandTimeout: TimeSpan.FromSeconds(30),
rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests).
using var locker = fixture.OpenNewConnection();
using (var begin = locker.CreateCommand())
{
begin.CommandText = "BEGIN EXCLUSIVE";
begin.ExecuteNonQuery();
}
try
{
var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
// The reader does exactly what it promises — Bad-coded snapshots, no exception…
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
// …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health.
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNullOrWhiteSpace();
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
}
finally
{
using var rollback = locker.CreateCommand();
rollback.CommandText = "ROLLBACK";
rollback.ExecuteNonQuery();
}
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
{
// The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when
// the Bad codes are connection-class. An authoring typo reporting "the database is down" would send
// an operator to the wrong system entirely.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
// ---- ISubscribable (poll-engine overlay) ----
[Fact]
public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var first = new TaskCompletionSource<DataChangeEventArgs>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changes = 0;
driver.OnDataChange += (_, args) =>
{
Interlocked.Increment(ref changes);
first.TrySetResult(args);
};
var handle = await driver.SubscribeAsync(
["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
initial.FullReference.ShouldBe("Speed");
initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
initial.SubscriptionHandle.ShouldBe(handle);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
driver.ActiveSubscriptionCount.ShouldBe(0);
// Unsubscribe awaits the loop task, so nothing may arrive after it returns.
var afterUnsubscribe = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
}
// ---- disposal ----
[Fact]
public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent()
{
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var changes = 0;
driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes);
_ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(300, TestContext.Current.CancellationToken);
await driver.DisposeAsync();
driver.ActiveSubscriptionCount.ShouldBe(0);
var afterDispose = Volatile.Read(ref changes);
await Task.Delay(400, TestContext.Current.CancellationToken);
Volatile.Read(ref changes).ShouldBe(afterDispose);
// Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw.
await driver.ShutdownAsync(CancellationToken.None);
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
// ---- host identity ----
[Fact]
public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials()
{
using var fixture = new SqlitePollFixture();
const string connectionString =
"Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";";
await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null);
var hosts = driver.GetHostStatuses();
hosts.Count.ShouldBe(1);
hosts[0].HostName.ShouldContain("sqlsrv01");
hosts[0].HostName.ShouldContain("MesStaging");
hosts[0].HostName.ShouldNotContain(SecretToken);
hosts[0].HostName.ShouldNotContain("svc_ot");
hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet
}
[Fact]
public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
var transitions = new List<HostStatusChangedEventArgs>();
driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } };
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// A second successful poll must not re-raise — the event is for transitions, not for ticks.
await driver.ReadAsync(["Speed"], CancellationToken.None);
lock (transitions)
{
transitions.Count.ShouldBe(1);
transitions[0].OldState.ShouldBe(HostState.Unknown);
transitions[0].NewState.ShouldBe(HostState.Running);
}
}
// ---- C1a: the credential guarantee, proven load-bearing ----
[Fact]
public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface()
{
// The vacuous sibling test drives SQLite's "unable to open database file" path, whose message
// structurally never contains the connection string — so it passes with or without any defensive
// code. This one fabricates a DbException whose OWN .Message carries a credential-like token and
// drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED
// against the pre-fix code that interpolated ex.Message into LastError and the thrown message.
const string leakyMessage = "login failed for user; Password=" + SecretToken;
var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
// A credential-free connection string: the ONLY place the token can come from is the exception.
"Server=sqlsrv01;Initial Catalog=Mes",
factory: new ThrowingConnectionFactory(leakyMessage),
logger: CapturingLogger.Null);
await using var _ = driver;
var thrown = await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
// The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status.
health.LastError!.ShouldNotContain(SecretToken);
thrown.Message.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
// …and it is still actionable: it names the endpoint the operator has to go look at.
health.LastError.ShouldContain("sqlsrv01");
}
// ---- I1: no double health-classification on a subscribed-read outage ----
[Fact]
public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce()
{
// On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a
// good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll
// engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync
// owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a
// credential-leak path) and logged the outage a second time.
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken));
var health = driver.GetHealth();
// Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked…
health.State.ShouldBe(DriverState.Healthy);
health.LastError.ShouldBeNull();
// …and it logged nothing (ReadAsync owns the single outage warning).
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0);
}
[Fact]
public void HandlePollError_forAnEngineContractViolation_stillDegrades()
{
// The falsifiability control for the guard above: a non-DbException (the poll engine's own
// reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health
// surface. The guard skips only the DbException connection class.
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots"));
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem()
{
// The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction —
// canonically the ArgumentException a malformed connection string's keyword parser throws, echoing
// credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is
// not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never
// ex.Message. Pre-fix this degraded with the raw message and leaked the token.
using var fixture = new SqlitePollFixture();
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
driver.HandlePollError(new ArgumentException(
"Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull();
health.LastError.ShouldNotContain(SecretToken); // never the message text
health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type)
}
// ---- I2: a late poll cannot un-fault a Faulted driver ----
[Fact]
public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver()
{
// The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to
// the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has
// recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only
// a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write.
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
await Should.ThrowAsync<Exception>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
// The stale in-flight poll completes with an all-Good batch.
driver.ObservePollOutcome(
[new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);
driver.GetHealth().State.ShouldBe(DriverState.Faulted); // stayed Faulted
}
// ---- I3: an omitted-type tag warns at discovery ----
[Fact]
public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Speed", SqlitePollFixture.PresentKey), // no "type" → discovered String
TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32")); // explicit "type"
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored"));
logger.Entries.ShouldNotContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored"));
}
// ---- helpers ----
/// <summary>A distinctive password token: any assertion that finds it has found a credential leak.</summary>
private const string SecretToken = "hunter2-do-not-log";
/// <summary>The unreachable connection string's database file name, used as the actionable-error probe.</summary>
private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";
/// <summary>
/// The driver's <c>DriverConfig</c> JSON. The shell serves its typed options (the factory, Task 9,
/// owns parsing) exactly as <c>ModbusDriver</c> does, so this is deliberately inert.
/// </summary>
private const string ConfigJson = """{"provider":"SqlServer"}""";
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, CapturingLogger.Null, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(fixture, fixture.ConnectionString, logger, rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags)
=> NewDriver(
fixture, connectionString, logger,
operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
rawTags: rawTags);
/// <summary>
/// Builds the driver through its production constructor. That constructor <b>is</b> the injection
/// seam — dialect, provider factory and already-resolved connection string are all parameters — so no
/// test-only factory is needed on the product type (Task 9 resolves the same three from config).
/// </summary>
private static SqlDriver NewDriver(
SqlitePollFixture fixture,
string connectionString,
CapturingLogger logger,
TimeSpan operationTimeout,
TimeSpan commandTimeout,
IReadOnlyList<RawTagEntry> rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
},
DriverInstanceId,
new SqliteDialect(),
connectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>A connection string whose database cannot be opened — the directory does not exist.</summary>
private static string Unreachable() => new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
Password = SecretToken,
}.ToString();
/// <summary>One authored raw tag: a key-value <c>TagConfig</c> blob over the fixture's EAV table.</summary>
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}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>A key-value entry carrying an explicit <c>"type"</c> — so <c>DeclaredType</c> is non-null.</summary>
private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{SqlitePollFixture.KeyValueTable}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}",
"type": "{{type}}"
}
"""), WriteIdempotent: false);
/// <summary>The same tag as <see cref="KvEntry"/>, already typed — for the reference reader.</summary>
private static SqlTagDefinition KvDefinition(string rawPath, string keyValue)
=> new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The folders created, in order.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Captures the driver's log so "skipped and logged" can be asserted rather than assumed.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
/// <summary>A logger that records nothing — for the tests that do not assert on logging.</summary>
public static CapturingLogger Null { get; } = new();
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbException"/> whose message is under the test's control — stands in for the
/// credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene
/// guarantee can be exercised without a live database.
/// </summary>
private sealed class FakeDbException(string message) : DbException(message);
/// <summary>
/// A <see cref="DbProviderFactory"/> whose connections always fail to open, with a
/// caller-supplied (credential-bearing) message — the seam that drives the driver's
/// unreachable-database path with a fabricated provider exception.
/// </summary>
private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory
{
public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage);
}
/// <summary>A connection that throws <see cref="FakeDbException"/> on open; every other member is inert.</summary>
private sealed class ThrowingConnection(string openFailureMessage) : DbConnection
{
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => string.Empty;
public override string DataSource => string.Empty;
public override string ServerVersion => string.Empty;
public override ConnectionState State => ConnectionState.Closed;
public override void Open() => throw new FakeDbException(openFailureMessage);
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
public override void Close() { }
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
}
}
@@ -0,0 +1,199 @@
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>
/// Pins <see cref="SqlEquipmentTagParser.TryParse"/> against its documented contract: what it accepts, what
/// it captures verbatim, and — the larger half — everything it must <b>reject</b>. Rejection is the whole
/// safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for
/// a typo'd enum) a node fed from the <em>wrong</em> model while publishing a confident <c>Good</c> (R2-11).
/// </summary>
public class SqlEquipmentTagParserTests
{
[Fact]
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
{
var json = """
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
def.DeclaredType.ShouldBe(DriverDataType.Float64);
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
// ---- WideRow: the where-pair selector ----
[Fact]
public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset()
{
var json = """
{"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp",
"timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.WideRow);
def.Table.ShouldBe("dbo.LatestStatus");
def.ColumnName.ShouldBe("oven_temp");
def.TimestampColumn.ShouldBe("sample_ts");
def.RowSelectorColumn.ShouldBe("station_id");
def.RowSelectorValue.ShouldBe("7");
def.RowSelectorTopByTimestamp.ShouldBeNull();
def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp");
// Wide-row carries none of the key-value fields.
def.KeyColumn.ShouldBeNull();
def.KeyValue.ShouldBeNull();
def.ValueColumn.ShouldBeNull();
def.DeclaredType.ShouldBeNull(); // no "type" authored ⇒ infer from column metadata
}
[Theory]
[InlineData("7", "7")] // JSON number → its raw token, so the reader binds one string either way
[InlineData("\"7\"", "7")] // JSON string → its contents
[InlineData("true", "true")] // JSON bool → its raw token (lower-case, as written)
public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar(
string authored, string expected)
{
var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":"""
+ authored + "}}";
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
def.RowSelectorValue.ShouldBe(expected);
}
[Fact]
public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim()
{
var json = """
{"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
"rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}}
""";
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
// Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values.
def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --");
}
// ---- WideRow: the topByTimestamp selector ----
[Fact]
public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset()
{
var json = """
{"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp",
"rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"}
""";
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
def.RowSelectorTopByTimestamp.ShouldBe("sample_ts");
def.RowSelectorColumn.ShouldBeNull();
def.RowSelectorValue.ShouldBeNull();
def.DeclaredType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair()
{
var json = """
{"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
"rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}}
""";
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
// SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair
// first and would otherwise silently drop the topByTimestamp ordering from a group's key.
def.RowSelectorColumn.ShouldBe("station_id");
def.RowSelectorValue.ShouldBe("7");
def.RowSelectorTopByTimestamp.ShouldBeNull();
}
// ---- WideRow rejections ----
[Theory]
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")]
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")]
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":" ","rowSelector":{"topByTimestamp":"ts"}}""")]
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")]
public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json)
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
[Theory]
// No rowSelector object at all.
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")]
// Present but empty.
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")]
// Half a where-pair is not a selector: a whereColumn with no value…
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")]
// …and a whereValue with no column.
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")]
// rowSelector authored as the wrong JSON kind is ignored, leaving no selector.
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")]
public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json)
{
// Accepting one would author a node whose query cannot say WHICH row it reads.
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
}
// ---- recognition + shared rejections ----
[Theory]
[InlineData("""["not","an","object"]""")]
[InlineData(""""just a string"""")]
[InlineData("42")]
[InlineData("null")]
[InlineData("not json at all")]
[InlineData("")]
[InlineData(" ")]
public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference)
=> SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse();
[Fact]
public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag()
{
// Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag.
const string json =
"""{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}""";
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
}
[Fact]
public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
[Fact]
public void TryParse_theDeferredQueryModel_rejectsTag()
{
// Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read.
const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""";
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
}
[Theory]
[InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":" ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
[InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
public void TryParse_withoutATable_rejectsTag(string json)
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
[Fact]
public void TryParse_invalidTypeEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""",
"raw", out _).ShouldBeFalse();
}
@@ -0,0 +1,386 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Pins the query-mapping core: which tags share a query (the GroupKey), the exact SQL emitted for each
/// group, and — the thing this type exists to guarantee — that <b>every authored value is a bound
/// parameter and never text</b>.
/// <para>GroupKey correctness governs read correctness: too coarse and tags silently read each other's
/// values; too fine and the batching collapses to one query per tag. Both failure modes are asserted
/// here.</para>
/// </summary>
public class SqlGroupPlannerTests
{
private static SqlTagDefinition KvTag(
string name, string table, string keyColumn, string keyValue, string valueColumn, string? timestampColumn)
=> new(name, SqlTagModel.KeyValue, table,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
TimestampColumn: timestampColumn);
private static SqlTagDefinition WideTag(
string name, string table, string columnName,
string? selectorColumn = null, string? selectorValue = null,
string? topByTimestamp = null, string? timestampColumn = null)
=> new(name, SqlTagModel.WideRow, table,
TimestampColumn: timestampColumn, ColumnName: columnName,
RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
// ---- the plan's golden case ----
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[]
{
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
[Fact]
public void KeyValuePlan_emitsTheWholeStatement_keyValueTimestampFromTheQuotedTable()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts")],
new SqlServerDialect());
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
plans[0].KeyColumn.ShouldBe("tag_name");
plans[0].ValueColumn.ShouldBe("num_value");
plans[0].TimestampColumn.ShouldBe("sample_ts");
}
[Fact]
public void KeyValuePlan_withoutATimestampColumn_omitsItFromTheSelectList()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
new SqlServerDialect());
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
plans[0].TimestampColumn.ShouldBeNull();
}
// ---- GroupKey: what must NOT fold together ----
[Fact]
public void KeyValueTags_onDifferentTables_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.OtherValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].GroupKey.ShouldNotBe(plans[1].GroupKey);
plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
plans[1].SqlText.ShouldContain("[dbo].[OtherValues]");
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentValueColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "str_value", "sample_ts"),
], new SqlServerDialect()).ToList();
// Folding these would make B read A's column — the silent cross-read this GroupKey prevents.
plans.Count.ShouldBe(2);
plans[0].SqlText.ShouldContain("[num_value]");
plans[1].SqlText.ShouldContain("[str_value]");
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentTimestampColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "recorded_at"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentKeyColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "dbo.TagValues", "alt_name", "Line1.Temp", "num_value", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
[Fact]
public void TagsOfDifferentModels_neverShareAPlan_evenOnTheSameTable()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.Latest", "tag_name", "Line1.Speed", "num_value", null),
WideTag("B", "dbo.Latest", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
plans[1].Model.ShouldBe(SqlTagModel.WideRow);
}
// ---- distinct parameter binding ----
[Fact]
public void KeyValueTags_sharingAKeyValue_bindTheKeyOnce_butKeepBothMembers()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("C", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" });
plans[0].ParameterNames.ShouldBe(new[] { "@k0", "@k1" });
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
// Both tags stay members: two OPC UA nodes are fed from the one row.
plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" });
}
[Fact]
public void ParameterNamesAndParameters_arePositionallyAligned_andMatchTheMarkersInTheSql()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
KvTag("C", "dbo.TagValues", "tag_name", "k2", "num_value", null),
], new SqlServerDialect()).ToList();
var plan = plans[0];
plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
}
// ---- wide row ----
[Fact]
public void WideRowTags_onTheSameRowSelector_foldIntoOnePlan_listingEachColumnOnce()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
WideTag("B", "dbo.LatestStatus", "pressure", selectorColumn: "station_id", selectorValue: "7"),
WideTag("C", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldBe(
"SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
plans[0].ParameterNames.ShouldBe(new[] { "@w" });
plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
plans[0].Members.Count.ShouldBe(3);
}
[Fact]
public void WideRowTags_onADifferentRowSelectorValue_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
WideTag("B", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "8"),
], new SqlServerDialect()).ToList();
// Folding these would publish station 7's temperature on station 8's node.
plans.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "7" });
plans[1].Parameters.ShouldBe(new object[] { "8" });
}
[Fact]
public void WideRowPlan_appendsEachDistinctMemberTimestampColumnAfterTheValueColumns()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp",
selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
WideTag("B", "dbo.LatestStatus", "pressure",
selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
plans[0].SqlText.ShouldBe(
"SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
}
[Fact]
public void WideRowTags_topByTimestamp_emitTheTop1OrderByDescForm_withNoParameters()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
WideTag("B", "dbo.Readings", "pressure", topByTimestamp: "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldBe(
"SELECT TOP 1 [oven_temp], [pressure] FROM [dbo].[Readings] ORDER BY [sample_ts] DESC");
plans[0].Parameters.ShouldBeEmpty();
plans[0].ParameterNames.ShouldBeEmpty();
}
[Fact]
public void WideRowTags_topByTimestamp_andAWherePair_onTheSameTable_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
WideTag("B", "dbo.Readings", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
// ---- identifier quoting ----
[Fact]
public void DottedTableName_isQuotedPerPart_notAsOneIdentifier()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
plans[0].SqlText.ShouldNotContain("[dbo.TagValues]"); // would name a nonexistent object
}
[Fact]
public void UndottedTableName_isQuotedAsASinglePart()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("FROM [TagValues] ");
}
[Fact]
public void ThreePartTableName_isQuotedPerPart()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "Plant.dbo.TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("[Plant].[dbo].[TagValues]");
}
[Fact]
public void AnEmptyTableNamePart_isRejected_ratherThanEmitted()
=> Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan(
[KvTag("A", "dbo..TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()));
// ---- the injection boundary ----
[Fact]
public void AHostileKeyValue_isBoundAsAParameter_andNeverReachesTheSqlText()
{
const string payload = "'; DROP TABLE x --";
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", payload, "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].Parameters.ShouldBe(new object[] { payload });
plans[0].SqlText.ShouldNotContain("DROP");
plans[0].SqlText.ShouldNotContain("--");
plans[0].SqlText.ShouldNotContain("'");
}
[Fact]
public void AHostileWideRowSelectorValue_isBoundAsAParameter_andNeverReachesTheSqlText()
{
const string payload = "7'); DROP TABLE x --";
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "dbo.LatestStatus", "oven_temp",
selectorColumn: "station_id", selectorValue: payload)],
new SqlServerDialect()).ToList();
plans[0].Parameters.ShouldBe(new object[] { payload });
plans[0].SqlText.ShouldNotContain("DROP");
}
[Fact]
public void AHostileColumnName_goesThroughQuoteIdentifier_ratherThanBeingConcatenatedRaw()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.T", "tag_name", "k", "v] ; DROP TABLE Users --", null)],
new SqlServerDialect()).ToList();
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
}
// ---- contract edges ----
[Fact]
public void NoTags_yieldNoPlans()
=> SqlGroupPlanner.Plan([], new SqlServerDialect()).ShouldBeEmpty();
[Fact]
public void PlanOrder_followsTheInputOrderOfTheFirstMemberOfEachGroup()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.Third", "k", "1", "v", null),
KvTag("B", "dbo.First", "k", "2", "v", null),
KvTag("C", "dbo.Third", "k", "3", "v", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "C" });
plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "B" });
}
[Fact]
public void TheDeferredQueryModel_throws_ratherThanBeingSilentlyDropped()
{
var tag = new SqlTagDefinition("A", SqlTagModel.Query, "dbo.T");
Should.Throw<NotSupportedException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
[Fact]
public void AWideRowTagWithNoRowSelectorAtAll_throws()
{
var tag = WideTag("A", "dbo.T", "oven_temp");
Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
}
@@ -0,0 +1,173 @@
using Microsoft.Data.Sqlite;
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>
/// Locks the driver's SQL-injection guarantee against a real database (<see cref="SqlitePollFixture"/>):
/// an authored <em>value</em> is bound as a parameter and can never execute, and an authored
/// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
/// cannot alter the database; the seed table is intact after every hostile poll.
/// <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
/// §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
/// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
/// is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
/// <c>SqlCatalogGateDriverTests</c>.</para>
/// <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
/// definitions that never pass through the gate, and that is the point: defence in depth is only worth
/// the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
/// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
/// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
/// (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
/// expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
/// single point of failure.</para>
/// </summary>
public sealed class SqlInjectionRegressionTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
/// <summary>The classic payload: were the key concatenated into text, this would drop the table.</summary>
private const string DropTablePayload = "'; DROP TABLE TagValues; --";
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
using var fixture = new SqlitePollFixture();
var reader = NewReader(fixture, KvTag("Speed", DropTablePayload));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
// Bound, not executed: it is simply a key that matches no row.
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
// The table is still there, with its seeded rows — the DROP never ran.
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
}
[Fact]
public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll()
{
// A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own
// parameter and matches nothing; the real key still reads.
using var fixture = new SqlitePollFixture();
var reader = NewReader(fixture,
KvTag("Evil", DropTablePayload),
KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
}
[Fact]
public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives()
{
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// A table name carrying a statement terminator + DROP. The reader is driven directly, so the
// catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
// nonexistent identifier, the query fails (no such table) with the connection already open, so the
// tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
// this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: "TagValues\"; DROP TABLE TagValues; --",
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
// Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
[Fact]
public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives()
{
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// The same attack via the value column: quoted, so it becomes a nonexistent column reference; the
// SELECT fails and the tag Bad-codes. Nothing executes.
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: "num_value\"; DROP TABLE TagValues; --",
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
[Fact]
public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase()
{
// QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object).
// The planner throws ArgumentException, which the reader classes as an authoring fault —
// BadConfigurationError — rather than a database failure. Still inert: the table survives.
using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
var reader = NewReader(fixture,
new SqlTagDefinition(
Name: "Evil",
Model: SqlTagModel.KeyValue,
Table: "Tag\0Values",
KeyColumn: SqlitePollFixture.KeyColumn,
KeyValue: SqlitePollFixture.PresentKey,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
(await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
}
// ---- helpers ----
private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
{
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
return new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: rawPath => table.GetValueOrDefault(rawPath));
}
private static SqlTagDefinition KvTag(string name, string keyValue)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
/// <summary>Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not
/// run. The table name is a test constant, never authored input, so interpolating it here is safe.</summary>
private static async Task<long> RowCountAsync(SqlitePollFixture fixture, string table)
{
await using var command = fixture.Connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
}
@@ -0,0 +1,651 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
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>
/// Proves <see cref="SqlPollReader"/> against a real database (<see cref="SqlitePollFixture"/>): the
/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct
/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven
/// by reading the code — that a query which refuses to return <b>does not wedge the caller</b>.
/// <para><b>Each test owns its own fixture.</b> The fixture is a temp file, so a fresh one per test is
/// cheap and buys total isolation — which matters here because two tests deliberately mutate the
/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into
/// every other test in the class.</para>
/// </summary>
public sealed class SqlPollReaderTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
// ---- the plan's headline contract: N in, N out, in input order ----
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Missing", SqlitePollFixture.AbsentKey),
KvTag("Temp", SqlitePollFixture.NullValueKey));
var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
// [0] present row, present cell — REAL infers Float64, so the published CLR type is double.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[0].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock
// [1] no row at all — NOT the same thing as a NULL cell.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshots[1].Value.ShouldBeNull();
// [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives).
snapshots[2].Value.ShouldBeNull();
SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue();
snapshots[2].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, nullIsBad: true,
tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]);
var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad);
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
// The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
var snapshots = await reader.ReadAsync(
["Speed", "NotAuthored", "Pressure"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The hole must not shift the tail — this is the ordering contract's real failure mode.
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
}
[Fact]
public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue()
{
// SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members,
// and a reader that indexed members by key into a 1:1 map would feed only one of them.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("SpeedA", SqlitePollFixture.PresentKey),
KvTag("SpeedB", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
}
[Fact]
public async Task ReadAsync_theSameRefTwice_feedsBothSlots()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null);
(await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty();
factory.Created.ShouldBe(0);
}
// ---- wide row ----
[Fact]
public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None);
// One row, two tags — the wrong-slice defect would cross these two values over.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure);
snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation),
WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation),
WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation));
var snapshots = await reader.ReadAsync(
["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector
SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL
snapshots[1].Value.ShouldBeNull();
snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected
}
[Fact]
public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None);
// Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here.
snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp);
}
[Fact]
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
{
using var fixture = new SqlitePollFixture();
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
// which of the two the reader publishes is decided by physical storage order — an authoring mistake
// (a selector column that is not actually unique) that must not pass silently. This is the WideRow
// counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning.
AddSecondRowForPresentStation(fixture);
var logger = new RecordingLogger();
var reader = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var warning = logger.Entries.ShouldHaveSingleItem();
warning.Level.ShouldBe(LogLevel.Warning);
// Names the table AND the selector, so an operator can find the offending tag from the log alone.
warning.Message.ShouldContain(SqlitePollFixture.WideRowTable);
warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn);
warning.Message.ShouldContain(SqlitePollFixture.PresentStation);
}
[Fact]
public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous()
{
using var fixture = new SqlitePollFixture();
var logger = new RecordingLogger();
// A selector that matches exactly one row is the normal case and must log nothing at all —
// without this leg a warning that always fires would still pass the test above.
var clean = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await clean.ReadAsync(["Oven"], CancellationToken.None);
logger.Entries.ShouldBeEmpty();
AddSecondRowForPresentStation(fixture);
var ambiguous = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
// A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding
// the log. Two polls, one warning.
logger.Entries.Count.ShouldBe(1);
}
// ---- type + timestamp mapping ----
[Fact]
public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32),
KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String),
KvTag("Inferred", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None);
snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins
snapshots[1].Value.ShouldBe("42"); // string
snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL
}
[Fact]
public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock()
{
using var fixture = new SqlitePollFixture();
var before = DateTime.UtcNow;
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].SourceTimestampUtc.ShouldNotBeNull();
snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
}
[Fact]
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
{
using var fixture = new SqlitePollFixture();
fixture.Execute(
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
// deterministic — last occurrence in reader order — rather than silently arbitrary.
snapshots[0].Value.ShouldBe(99.0);
}
// ---- the frozen-peer contract ----
[Fact]
public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline()
{
using var fixture = new SqlitePollFixture();
// A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and
// Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a
// cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async
// API that ignores its deadline), so only a real wall-clock bound can end this call early.
using var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE");
try
{
// Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the
// point is to prove the CLIENT-side bound fires while the server-side backstop would still wait.
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var clock = Stopwatch.StartNew();
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
clock.Stop();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
snapshots[0].Value.ShouldBeNull();
// Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s.
clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
finally
{
Execute(locker, "ROLLBACK");
}
}
[Fact]
public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes()
{
// The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group
// is still holding a connection, so handing its slot back at the deadline would let the next poll
// open another one — and a database that stays frozen would accumulate one connection per poll pass
// forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test
// above never times a group out, and the BadTimeout test above never counts connections.
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
// commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay
// wedged for as long as the lock is held, so nothing but the test releases the slot.
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 1, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws
try
{
// Poll 1 wedges against the lock and times out, abandoning a still-running query.
var first = await reader.ReadAsync(["Speed"], CancellationToken.None);
first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1); // the zombie still owns its connection
// (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the
// factory: it times out waiting on the gate. Created staying at 1 is the load-bearing
// assertion — release-from-the-waiter would make this 2.
var second = await reader.ReadAsync(["Speed"], CancellationToken.None);
second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1);
}
finally
{
Execute(locker, "ROLLBACK");
locker.Dispose();
}
// (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline),
// closes its connection, and only then hands the slot back.
(await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the abandoned group never released its connection");
var third = await reader.ReadAsync(["Speed"], CancellationToken.None);
third[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished
factory.Live.ShouldBe(0);
}
[Fact]
public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation()
{
// Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so
// clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must
// propagate rather than be laundered into a snapshot nobody will consume.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
async () => await reader.ReadAsync(["Speed"], cancelled.Token));
}
[Fact]
public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff()
{
// IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws.
using var fixture = new SqlitePollFixture();
var unreachable = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"),
}.ToString();
var reader = new SqlPollReader(
fixture.Factory, unreachable, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
await Should.ThrowAsync<DbException>(
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
}
// ---- connection lifecycle ----
[Theory]
[InlineData(1)]
[InlineData(3)]
public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups)
{
using var fixture = new SqlitePollFixture();
// Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp.
var tags = new[]
{
KvTag("Speed", SqlitePollFixture.PresentKey),
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn),
};
// The delay makes the groups genuinely overlap; without it a SQLite query is too fast for
// concurrency to be observable at all and the cap assertion below would pass vacuously.
var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150));
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30),
maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags));
var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None);
snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good);
factory.Created.ShouldBe(3);
factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real
factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop
}
[Fact]
public async Task ReadAsync_repeatedPolls_leakNoConnections()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
for (var poll = 0; poll < 5; poll++)
{
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
factory.Created.ShouldBe(5);
factory.Live.ShouldBe(0);
}
// ---- argument guards ----
[Fact]
public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap()
{
var dialect = new SqliteDialect();
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null));
Should.Throw<ArgumentException>(() => new SqlPollReader(
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
}
[Fact]
public async Task ReadAsync_rejectsANullReferenceList()
=> await Should.ThrowAsync<ArgumentNullException>(async () =>
{
using var fixture = new SqlitePollFixture();
await Reader(fixture).ReadAsync(null!, CancellationToken.None);
});
// ---- helpers ----
private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
=> Reader(fixture, nullIsBad: false, tags: tags);
private static SqlPollReader Reader(
SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags));
private static SqlPollReader Reader(
SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger);
/// <summary>
/// Adds a second <see cref="SqlitePollFixture.WideRowTable"/> row for the station the wide-row tests
/// select, breaking that model's one-row-per-selector contract.
/// </summary>
private static void AddSecondRowForPresentStation(SqlitePollFixture fixture)
=> fixture.Execute(string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO {SqlitePollFixture.WideRowTable}
({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure,
{SqlitePollFixture.TimestampColumn})
VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z')
"""));
/// <summary>Runs one statement on a caller-owned connection.</summary>
private static void Execute(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>
/// Polls <paramref name="condition"/> until it holds or <paramref name="limit"/> elapses. Bounded on
/// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it
/// does not.
/// </summary>
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan limit)
{
var clock = Stopwatch.StartNew();
while (clock.Elapsed < limit)
{
if (condition()) return true;
await Task.Delay(25);
}
return condition();
}
/// <summary>The driver's RawPath→definition table, standing in for <c>EquipmentTagRefResolver</c>.</summary>
private static Func<string, SqlTagDefinition?> Resolver(params SqlTagDefinition[] tags)
{
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
return rawPath => table.GetValueOrDefault(rawPath);
}
private static SqlTagDefinition KvTag(
string name,
string keyValue,
DriverDataType? declaredType = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: timestampColumn,
DeclaredType: declaredType);
private static SqlTagDefinition WideTag(
string name,
string columnName,
string? selectorValue = null,
string? topByTimestamp = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
ColumnName: columnName,
TimestampColumn: timestampColumn,
RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
/// <summary>
/// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the
/// contract-violation warnings are the reader's only signal that a source is misauthored, so they are
/// behaviour, not diagnostics.
/// </summary>
private sealed class RecordingLogger : ILogger
{
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class Scope : IDisposable
{
public static Scope Instance { get; } = new();
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbProviderFactory"/> over SQLite that counts how many connections the reader has
/// created and how many are alive at once — the only way to observe the connection lifecycle from
/// outside, since <see cref="SqlPollReader"/> deliberately owns its connections end to end.
/// <para>The optional <c>createDelay</c> widens the window each connection is alive so that
/// concurrent group execution is actually observable against a database whose queries would
/// otherwise finish in microseconds.</para>
/// </summary>
private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory
{
private readonly object _sync = new();
private int _live;
private int _peak;
private int _created;
/// <summary>How many connections the reader has asked the factory for.</summary>
public int Created { get { lock (_sync) { return _created; } } }
/// <summary>How many created connections have not yet closed.</summary>
public int Live { get { lock (_sync) { return _live; } } }
/// <summary>The high-water mark of <see cref="Live"/>.</summary>
public int Peak { get { lock (_sync) { return _peak; } } }
public override DbConnection CreateConnection()
{
var connection = SqliteFactory.Instance.CreateConnection()!;
connection.StateChange += OnStateChange;
lock (_sync)
{
_created++;
_live++;
if (_live > _peak) _peak = _live;
}
if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay);
return connection;
}
private void OnStateChange(object sender, StateChangeEventArgs e)
{
if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return;
lock (_sync) { _live--; }
}
}
}
@@ -0,0 +1,34 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
public class SqlQueryPlanImmutabilityTests
{
// A plan is documented as cacheable across polls. If it stayed aliased to the planner's working
// List<T>, a consumer downcast could corrupt every later poll that reused it.
[Fact]
public void Plan_doesNotAliasTheCallersLists()
{
var names = new List<string> { "@k0" };
var values = new List<object?> { "v" };
var members = new List<SqlTagDefinition>
{
new("raw", SqlTagModel.KeyValue, "t") { KeyColumn = "k", KeyValue = "v", ValueColumn = "c" },
};
var selected = new List<string> { "k", "c" };
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
names.Add("@k1");
values.Add("other");
members.Add(members[0]);
selected.Add("extra");
plan.ParameterNames.Count.ShouldBe(1);
plan.Parameters.Count.ShouldBe(1);
plan.Members.Count.ShouldBe(1);
plan.SelectedColumns.Count.ShouldBe(2);
}
}
@@ -0,0 +1,143 @@
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>
/// Golden tests for the driver's SQL-injection boundary. Values are always bound as
/// <c>DbParameter</c>s; identifiers cannot be, so <see cref="SqlServerDialect.QuoteIdentifier"/> is the
/// only thing standing between a catalog-sourced name and a command text. These tests pin the escape
/// rule, the rejection rules, and the exact catalog SQL.
/// </summary>
public class SqlServerDialectTests
{
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
[Theory]
[InlineData("x\u0001y")] // C0 SOH
[InlineData("x\u001By")] // C0 ESC
[InlineData("x\u007Fy")] // DEL
[InlineData("x\u0085y")] // C1 NEL
[InlineData("x\ty")]
[InlineData("x\ny")]
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all
// of these. They cannot escape the brackets, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData("Sp\u200Beed")] // zero-width space
[InlineData("\u202Ediop.selbat")] // right-to-left override
[InlineData("x\u2066y\u2069")] // bidi isolates
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
[InlineData("x\u00ADy")] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
[Fact]
public void SingleRowLimit_isTSqlsTop1_inThePrefixPosition_withItsOwnTrailingSpace()
{
var d = new SqlServerDialect();
// The planner concatenates "SELECT " + prefix + columns with no separator of its own, so the
// trailing space belongs to the fragment. T-SQL has no end-of-statement limit clause.
d.SingleRowLimitPrefix.ShouldBe("TOP 1 ");
d.SingleRowLimitSuffix.ShouldBe("");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "[a]", d.SingleRowLimitSuffix)
.ShouldBe("SELECT TOP 1 [a]");
}
// ---- extra guards on the injection boundary (beyond the plan's golden set) ----
[Fact]
public void Dialect_identifiesItselfAsSqlServer_andExposesTheAbstractFactory()
{
var d = new SqlServerDialect();
d.Provider.ShouldBe(SqlProvider.SqlServer);
d.Factory.ShouldNotBeNull();
// The seam must not leak Microsoft.Data.SqlClient into its signature.
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident!));
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButABracket()
=> new SqlServerDialect().QuoteIdentifier("]").ShouldBe("[]]]"); // T-SQL for the 1-char name "]"
[Fact]
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
{
var d = new SqlServerDialect();
d.QuoteIdentifier(new string('a', 128)).ShouldBe("[" + new string('a', 128) + "]");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// A hostile "column name" cannot escape the brackets: the only metacharacter that could is ],
// and it is doubled. The result is a single (nonexistent) identifier, never executable SQL.
new SqlServerDialect().QuoteIdentifier("x] ; DROP TABLE Users --")
.ShouldBe("[x]] ; DROP TABLE Users --]");
}
[Theory]
[InlineData("NVARCHAR", DriverDataType.String)]
[InlineData("BiT", DriverDataType.Boolean)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("geography")]
[InlineData("sql_variant")]
[InlineData("timestamp")] // SQL Server rowversion — binary, deliberately NOT a DateTime
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new SqlServerDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
}
@@ -0,0 +1,160 @@
using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
/// be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
/// real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
/// <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
/// <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
/// <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
/// <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
/// (<c>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</c>)
/// rather than referencing this project, so it must not depend on anything else defined here.</para>
/// </summary>
public sealed class SqliteDialect : ISqlDialect
{
/// <summary>
/// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the
/// catalog queries accept this one value so the browser's schema level has something real to expand.
/// </summary>
public const string MainSchema = "main";
/// <summary>
/// Reports <see cref="SqlProvider.Odbc"/>.
/// <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> is a shipped product
/// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain
/// a case that can never occur in production.</para>
/// <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
/// not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
/// <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
/// <see cref="Provider"/> stays switched off against this dialect and fails visibly here rather than
/// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp
/// for exactly the assumptions it exists to catch.</para>
/// </summary>
public SqlProvider Provider => SqlProvider.Odbc;
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
/// position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
/// ends on the seam.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// SQLite has no schema namespace, so the schema level is the single literal
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
/// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
/// </summary>
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
/// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
/// <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
/// honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
/// </summary>
public string ListTablesSql =>
"SELECT name AS TABLE_NAME, " +
"CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " +
"FROM sqlite_schema " +
"WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " +
$"AND @schema = '{MainSchema}' ORDER BY name";
/// <summary>
/// Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
/// <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
/// <b>bound parameter</b> — a bare PRAGMA takes its argument as text, which would put an authored name
/// back into a command string and reopen the injection boundary this seam exists to close.
/// <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
/// </summary>
public string ListColumnsSql =>
"SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " +
"CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " +
"FROM pragma_table_info(@table) " +
$"WHERE @schema = '{MainSchema}' ORDER BY cid";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
/// only metacharacter that could close a quoted identifier early.
/// </summary>
/// <remarks>
/// Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'s rejections (null / empty / all-whitespace /
/// control characters) so a test written against one dialect fails the same way against the other.
/// SQLite imposes no identifier length ceiling, so there is no length rule here.
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quote-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQLite identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
}
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
}
/// <summary>
/// Folds a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
/// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
/// declares every seeded column explicitly.
/// </summary>
/// <remarks>
/// <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
/// parameterised like <c>VARCHAR(50)</c>) declaration falls back to
/// <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
/// <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
/// <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
/// <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
/// </remarks>
/// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"boolean" or "bool" or "bit" => DriverDataType.Boolean,
"tinyint" or "smallint" or "int2" => DriverDataType.Int16,
"int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32,
"bigint" or "int8" => DriverDataType.Int64,
// SQLite's REAL is a double — deliberately NOT Float32 (see remarks).
"real" or "double" or "double precision" or "float"
or "numeric" or "decimal" => DriverDataType.Float64,
"text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String,
"date" or "datetime" or "timestamp" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}
@@ -0,0 +1,212 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate
/// for every reader test. It exists so the poll path is exercised against a genuine
/// <see cref="DbConnection"/>: real parameter binding, real provider type coercion, real
/// <see cref="DBNull"/>, real "no such row".
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c>.</b> The reader opens a <em>fresh</em>
/// connection per poll (<c>Factory.CreateConnection()</c> → set <see cref="ConnectionString"/> →
/// <c>OpenAsync</c>) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment
/// its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate
/// between polls and every test would fail for a reason that has nothing to do with the code under test.
/// The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an
/// invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to
/// exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no
/// keep-alive, and its connection string is an ordinary path the code under test takes verbatim.</para>
/// <para><b>The fixture is a contract, not scaffolding.</b> The seeded shapes below are what reader tests
/// assert against, so the constants are public and the seed deliberately covers the three cases that
/// behave differently: a present value, a present row whose value cell is <b>NULL</b>, and a key that is
/// <b>absent</b> entirely. Change a constant and you are changing what the reader is proven to do.</para>
/// </summary>
public sealed class SqlitePollFixture : IDisposable
{
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by both seeded tables.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>
/// A key whose row <b>exists</b> but whose value cell is <c>NULL</c> — the case that must not be
/// confused with <see cref="AbsentKey"/>: the row was read, the value is simply not there.
/// </summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>
/// A key with <b>no row at all</b>. Distinct from <see cref="NullValueKey"/> on purpose: a missing row
/// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
/// </summary>
public const string AbsentKey = "Line1.Missing";
// ---- wide-row source ----
/// <summary>
/// The wide-row table:
/// <c>LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT)</c>.
/// </summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> row selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
/// <c>ORDER BY … DESC</c> (or the row limit) picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — the value a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
private readonly string _databasePath;
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
public SqlitePollFixture()
{
_databasePath = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db");
ConnectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
}.ToString();
Connection = new SqliteConnection(ConnectionString);
Connection.Open();
Seed(Connection);
}
/// <summary>
/// The connection string to hand the code under test. An ordinary file path — safe to open and close
/// any number of times, from any number of connections, in any order.
/// </summary>
public string ConnectionString { get; }
/// <summary>
/// The provider factory to hand the code under test, matching the reader's
/// <c>(DbProviderFactory factory, string connectionString, …)</c> seam.
/// <para>This is the <em>real</em> <see cref="SqliteFactory"/>, not a shim that hands back a
/// pre-opened connection: because the database is a file, the reader's genuine
/// create → open → query → close cycle works unmodified, so what the tests exercise is the production
/// connection lifecycle rather than a test-only shortcut around it.</para>
/// </summary>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <summary>
/// A long-lived open connection over the same database, for arranging extra state or inspecting
/// results directly. Independent of the connections the code under test opens — closing one has no
/// effect on the others, and none of them destroy the data.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>Opens a brand-new connection, exactly as the reader does on each poll.</summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Runs one non-query statement against <see cref="Connection"/>, for test-local arrangement.</summary>
/// <param name="sql">The statement to execute.</param>
public void Execute(string sql)
{
using var command = Connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>Closes the fixture's connection, clears the pool, and deletes the temporary database file.</summary>
public void Dispose()
{
Connection.Close();
Connection.Dispose();
// Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be
// held open and the delete silently fails, leaking a file per test class into the temp directory.
SqliteConnection.ClearAllPools();
try
{
if (File.Exists(_databasePath)) File.Delete(_databasePath);
}
catch (IOException)
{
// A leaked temp file must never fail a test run.
}
}
/// <summary>
/// Applies the schema and rows described by this type's constants.
/// <para>Every column is <b>explicitly declared</b>. SQLite is dynamically typed and would happily
/// store a string in an undeclared column, which would make the reader's type-coercion tests prove
/// nothing; declared types give <c>pragma_table_info</c> (and therefore
/// <see cref="SqliteDialect.MapColumnType"/>) something real to report.</para>
/// </summary>
private static void Seed(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
CREATE TABLE {KeyValueTable} (
{KeyColumn} TEXT NOT NULL,
{ValueColumn} REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES
('{PresentKey}', {PresentValue}, '2026-07-24T10:00:00Z'),
('{NullValueKey}', NULL, '2026-07-24T10:00:01Z'),
('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z');
CREATE TABLE {WideRowTable} (
{WideRowSelectorColumn} INTEGER NOT NULL,
oven_temp REAL NULL,
pressure REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES
({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01Z'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02Z');
""");
command.ExecuteNonQuery();
}
}
@@ -0,0 +1,263 @@
using Microsoft.Data.Sqlite;
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>
/// Proves the offline substrate the reader tests will stand on: that <see cref="SqlitePollFixture"/>
/// survives the reader's real per-poll connection lifecycle, and that a
/// <see cref="SqlGroupPlanner"/>-produced plan <b>executes</b> — parses, binds, and returns the seeded
/// rows — rather than merely looking right as a string.
/// <para>The planner's own tests assert emitted SQL text. Nothing there can catch a statement that is
/// well-formed but unexecutable, or a parameter marker the provider will not bind. These tests can, and
/// they are the reason the next task starts from a known-good query path.</para>
/// </summary>
public sealed class SqlitePollFixtureTests : IClassFixture<SqlitePollFixture>
{
private readonly SqlitePollFixture _fixture;
public SqlitePollFixtureTests(SqlitePollFixture fixture) => _fixture = fixture;
// ---- the fixture itself ----
[Fact]
public void AConnectionFromTheFactory_roundTripsASeededRow()
{
// Exactly the reader's seam: create from the abstract factory, assign the connection string, open.
var connection = _fixture.Factory.CreateConnection();
connection.ShouldNotBeNull();
connection.ConnectionString = _fixture.ConnectionString;
connection.Open();
using var owned = connection;
using var command = owned.CreateCommand();
command.CommandText =
$"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
$"WHERE {SqlitePollFixture.KeyColumn} = @k";
var parameter = command.CreateParameter();
parameter.ParameterName = "@k";
parameter.Value = SqlitePollFixture.PresentKey;
command.Parameters.Add(parameter);
Convert.ToDouble(command.ExecuteScalar()).ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public void TheDatabase_survivesTheReadersOpenAndCloseCyclePerPoll()
{
// The whole reason the fixture is file-backed: an in-memory database would be gone by "poll" 2.
for (var poll = 0; poll < 3; poll++)
{
using var connection = _fixture.OpenNewConnection();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM {SqlitePollFixture.KeyValueTable}";
Convert.ToInt64(command.ExecuteScalar()).ShouldBe(3L);
}
}
[Fact]
public void TheSeed_distinguishesAPresentValue_aNullCell_andAnAbsentKey()
{
using var connection = _fixture.OpenNewConnection();
ReadValue(connection, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
ReadValue(connection, SqlitePollFixture.NullValueKey).ShouldBe(DBNull.Value); // row present, cell NULL
ReadValue(connection, SqlitePollFixture.AbsentKey).ShouldBeNull(); // no row at all
static object? ReadValue(SqliteConnection connection, string key)
{
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
$"WHERE {SqlitePollFixture.KeyColumn} = @k";
command.Parameters.AddWithValue("@k", key);
return command.ExecuteScalar();
}
}
// ---- planner-produced plans, actually executed ----
[Fact]
public void AKeyValuePlan_executesAgainstTheFixture_andSlicesBackPerMember()
{
var plan = SqlGroupPlanner.Plan(
[
KvTag("A", SqlitePollFixture.PresentKey),
KvTag("B", SqlitePollFixture.NullValueKey),
KvTag("C", SqlitePollFixture.AbsentKey),
], new SqliteDialect()).ShouldHaveSingleItem();
var rows = ExecutePlan(plan);
// The result set is indexed by the key column, exactly as the reader will slice it.
var byKey = rows.ToDictionary(
r => (string)r[SqlitePollFixture.KeyColumn]!, StringComparer.Ordinal);
byKey.Count.ShouldBe(2); // the absent key contributes no row — it is not an error, just no data
byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.ValueColumn]
.ShouldBe(SqlitePollFixture.PresentValue);
byKey[SqlitePollFixture.NullValueKey][SqlitePollFixture.ValueColumn]
.ShouldBeNull(); // present row, NULL cell — distinct from the absent key above
byKey.ShouldNotContainKey(SqlitePollFixture.AbsentKey);
byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.TimestampColumn]
.ShouldBe("2026-07-24T10:00:00Z");
}
[Fact]
public void AWideRowWherePairPlan_executesAgainstTheFixture_andReturnsTheSelectedRow()
{
var plan = SqlGroupPlanner.Plan(
[
WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("B", "pressure", selectorValue: SqlitePollFixture.PresentStation),
], new SqliteDialect()).ShouldHaveSingleItem();
// The station id is bound, not inlined — and SQLite coerces the bound string against an INTEGER column.
plan.Parameters.ShouldBe(new object[] { SqlitePollFixture.PresentStation });
var row = ExecutePlan(plan).ShouldHaveSingleItem();
row["oven_temp"].ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
row["pressure"].ShouldBe(SqlitePollFixture.PresentStationPressure);
}
[Fact]
public void AWideRowWherePairPlan_forAnAbsentSelectorValue_returnsNoRows()
{
var plan = SqlGroupPlanner.Plan(
[WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation)],
new SqliteDialect()).ShouldHaveSingleItem();
ExecutePlan(plan).ShouldBeEmpty();
}
[Fact]
public void ATopByTimestampPlan_emitsSqlitesLimitForm_andExecutesToTheNewestRow()
{
var plan = SqlGroupPlanner.Plan(
[WideTag("A", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)],
new SqliteDialect()).ShouldHaveSingleItem();
// The payoff of putting the row limit on ISqlDialect: T-SQL's "TOP 1" does not parse in SQLite, so
// this statement would throw at ExecuteReader if the planner had kept emitting it inline.
plan.SqlText.ShouldBe(
"SELECT \"oven_temp\" FROM \"LatestStatus\" ORDER BY \"sample_ts\" DESC LIMIT 1");
plan.SqlText.ShouldNotContain("TOP 1");
var row = ExecutePlan(plan).ShouldHaveSingleItem();
row["oven_temp"].ShouldBe(SqlitePollFixture.NewestStationOvenTemp); // station 8, not the first row
}
// ---- the dialect ----
[Fact]
public void TheDialect_quotesWithDoubleQuotes_andDoublesAnEmbeddedOne()
{
var dialect = new SqliteDialect();
dialect.QuoteIdentifier("oven_temp").ShouldBe("\"oven_temp\"");
dialect.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
Should.Throw<ArgumentException>(() => dialect.QuoteIdentifier("x\0y"));
Should.Throw<ArgumentException>(() => dialect.QuoteIdentifier(" "));
}
[Fact]
public void TheDialectsCatalogSql_answersOverTheRealSeededDatabase()
{
var dialect = new SqliteDialect();
using var connection = _fixture.OpenNewConnection();
Query(dialect.ListSchemasSql).ShouldBe(new[] { SqliteDialect.MainSchema });
var tables = Query(dialect.ListTablesSql, ("@schema", SqliteDialect.MainSchema));
tables.ShouldContain(SqlitePollFixture.KeyValueTable);
tables.ShouldContain(SqlitePollFixture.WideRowTable);
tables.ShouldAllBe(t => !t.StartsWith("sqlite_", StringComparison.Ordinal));
var columns = Query(
dialect.ListColumnsSql,
("@schema", SqliteDialect.MainSchema), ("@table", SqlitePollFixture.WideRowTable));
columns.ShouldBe(new[]
{
SqlitePollFixture.WideRowSelectorColumn, "oven_temp", "pressure", SqlitePollFixture.TimestampColumn,
});
List<string> Query(string sql, params (string Name, string Value)[] parameters)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value);
using var reader = command.ExecuteReader();
var results = new List<string>();
while (reader.Read()) results.Add(reader.GetString(0));
return results;
}
}
[Fact]
public void TheDialect_mapsTheSeededDeclaredTypes_andNeverThrowsOnAnUnknownAffinity()
{
var dialect = new SqliteDialect();
dialect.MapColumnType("TEXT").ShouldBe(DriverDataType.String);
dialect.MapColumnType("INTEGER").ShouldBe(DriverDataType.Int32);
// SQLite's REAL is a double — NOT Float32, which is what the same word means in T-SQL.
dialect.MapColumnType("REAL").ShouldBe(DriverDataType.Float64);
dialect.MapColumnType("real").ShouldBe(DriverDataType.Float64);
dialect.MapColumnType("VARCHAR(50)").ShouldBe(DriverDataType.String); // unparsed ⇒ fallback
dialect.MapColumnType("some_udt").ShouldBe(DriverDataType.String);
dialect.MapColumnType("").ShouldBe(DriverDataType.String);
dialect.MapColumnType(null!).ShouldBe(DriverDataType.String);
}
[Fact]
public void TheDialect_doesNotClaimToBeSqlServer()
// A test dialect that reported SqlServer would rubber-stamp the T-SQL assumptions it exists to catch.
=> new SqliteDialect().Provider.ShouldNotBe(SqlProvider.SqlServer);
// ---- helpers ----
private static SqlTagDefinition KvTag(string name, string keyValue)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: SqlitePollFixture.TimestampColumn);
private static SqlTagDefinition WideTag(
string name, string columnName, string? selectorValue = null, string? topByTimestamp = null)
=> new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
ColumnName: columnName,
RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
/// <summary>
/// Executes a plan the way the reader will: one fresh connection, the plan's SQL verbatim, its
/// parameters bound positionally by name, and the rows projected by
/// <see cref="SqlQueryPlan.SelectedColumns"/>.
/// </summary>
private List<Dictionary<string, object?>> ExecutePlan(SqlQueryPlan plan)
{
using var connection = _fixture.OpenNewConnection();
using var command = connection.CreateCommand();
command.CommandText = plan.SqlText;
for (var i = 0; i < plan.ParameterNames.Count; i++)
command.Parameters.AddWithValue(plan.ParameterNames[i], plan.Parameters[i] ?? DBNull.Value);
using var reader = command.ExecuteReader();
var rows = new List<Dictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var column in plan.SelectedColumns)
{
var ordinal = reader.GetOrdinal(column);
row[column] = reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal);
}
rows.Add(row);
}
return rows;
}
}
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise driver-level behavior — the analyzer's documented intentional case ("move the
suppression into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!--
TEST-ONLY. SQLite backs SqlitePollFixture so the poll reader can be exercised against a real
DbConnection offline; no product project takes a dependency on it (Driver.Sql ships
Microsoft.Data.SqlClient only). The bundle_e_sqlite3 line is the same surgical direct pin
Core.AlarmHistorian carries — it promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range that Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
See Directory.Packages.props.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
/// </summary>
/// <remarks>
/// The bug being pinned: a range operator over an unvalidated DB string throws
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
/// that matter; the happy path is almost incidental.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DisplayTextTests
{
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
/// case for a 64-char SHA-256.</summary>
[Fact]
public void A_long_value_is_truncated_and_marked()
{
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
}
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
[Theory]
[InlineData("h1", 12)]
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
[InlineData("abc", 16)]
[InlineData("x", 64)]
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
{
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
}
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
[Fact]
public void A_short_value_gets_no_ellipsis()
{
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
}
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
/// marked.</summary>
[Fact]
public void A_value_exactly_at_the_budget_is_untouched()
{
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
}
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
/// producing a stray ellipsis.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_null_or_blank_value_renders_the_placeholder(string? value)
{
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
}
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
/// become a page crash, which is the whole point of this helper.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
{
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
}
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
/// page. Includes the shapes that broke the old code.</summary>
[Fact]
public void It_never_throws_for_any_combination()
{
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
foreach (var value in values)
{
foreach (var budget in budgets)
{
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
}
}
}
}
@@ -0,0 +1,89 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
/// </summary>
public sealed class SqlDriverFormContractTests
{
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
private static readonly JsonSerializerOptions FormOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
[Theory]
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
{
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
driver.ShouldNotBeNull();
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Factory_rejects_a_blob_missing_connectionStringRef()
{
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
[Fact]
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
{
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(dto, FormOptions);
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
}
[Fact]
public void Provider_round_trips_as_the_SqlServer_name()
{
var json = JsonSerializer.Serialize(
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
json.ShouldContain("\"SqlServer\"");
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
}
}
@@ -0,0 +1,277 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔
/// parser-input agreement: what <see cref="SqlTagConfigModel.ToJson"/> writes MUST parse cleanly through
/// <see cref="SqlEquipmentTagParser.TryParse"/> (the runtime factory's authoritative reader), and the
/// <c>model</c>/<c>type</c> enums MUST serialise as NAME strings — a numeric enum makes the parser reject
/// the tag (the "authors fine, never reads" defect class this guards).
/// </summary>
public sealed class SqlTagConfigModelTests
{
// ---- the plan's required assertion: string enums + unknown-key survival --------------------
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
var m = SqlTagConfigModel.FromJson(
"""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
var json = m.ToJson();
json.ShouldContain("\"KeyValue\"");
json.ShouldContain("\"Float64\"");
json.ShouldContain("customX"); // unknown key survives load→save
json.ShouldNotContain("\"model\":0");
json.ShouldNotContain("\"type\":8"); // no numeric enum leaks
}
// ---- defaults ------------------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = SqlTagConfigModel.FromJson(json);
m.Model.ShouldBe(SqlTagModel.KeyValue);
m.Table.ShouldBeNull();
m.KeyColumn.ShouldBeNull();
m.KeyValue.ShouldBeNull();
m.ValueColumn.ShouldBeNull();
m.TimestampColumn.ShouldBeNull();
m.ColumnName.ShouldBeNull();
m.Type.ShouldBeNull();
m.RowSelector.WhereColumn.ShouldBeNull();
m.RowSelector.WhereValue.ShouldBeNull();
m.RowSelector.TopByTimestamp.ShouldBeNull();
}
[Fact]
public void ToJson_of_default_model_omits_optional_keys()
{
var json = new SqlTagConfigModel { Table = "t" }.ToJson();
json.ShouldContain("\"model\":\"KeyValue\"");
json.ShouldContain("\"table\":\"t\"");
json.ShouldNotContain("type"); // no type set ⇒ key omitted
json.ShouldNotContain("rowSelector"); // no selector ⇒ key omitted
json.ShouldNotContain("timestampColumn");
}
// ---- round-trip fidelity through FromJson→ToJson→FromJson ----------------------------------
[Fact]
public void Round_trip_preserves_keyValue_fields()
{
var m = new SqlTagConfigModel
{
Model = SqlTagModel.KeyValue,
Table = "dbo.TagValues",
KeyColumn = "TagName",
KeyValue = "Line1.Speed",
ValueColumn = "Val",
TimestampColumn = "Ts",
Type = DriverDataType.Float64,
};
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
m2.Model.ShouldBe(SqlTagModel.KeyValue);
m2.Table.ShouldBe("dbo.TagValues");
m2.KeyColumn.ShouldBe("TagName");
m2.KeyValue.ShouldBe("Line1.Speed");
m2.ValueColumn.ShouldBe("Val");
m2.TimestampColumn.ShouldBe("Ts");
m2.Type.ShouldBe(DriverDataType.Float64);
}
[Fact]
public void Round_trip_preserves_wideRow_wherePair_fields()
{
var m = new SqlTagConfigModel
{
Model = SqlTagModel.WideRow,
Table = "dbo.Wide",
ColumnName = "Speed",
RowSelector = new SqlRowSelectorModel { WhereColumn = "DeviceId", WhereValue = "42" },
};
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
m2.Model.ShouldBe(SqlTagModel.WideRow);
m2.Table.ShouldBe("dbo.Wide");
m2.ColumnName.ShouldBe("Speed");
m2.RowSelector.WhereColumn.ShouldBe("DeviceId");
m2.RowSelector.WhereValue.ShouldBe("42");
m2.RowSelector.TopByTimestamp.ShouldBeNull();
}
[Fact]
public void Round_trip_preserves_wideRow_topByTimestamp_fields()
{
var m = new SqlTagConfigModel
{
Model = SqlTagModel.WideRow,
Table = "dbo.Wide",
ColumnName = "Speed",
RowSelector = new SqlRowSelectorModel { TopByTimestamp = "Ts" },
};
var m2 = SqlTagConfigModel.FromJson(m.ToJson());
m2.Model.ShouldBe(SqlTagModel.WideRow);
m2.ColumnName.ShouldBe("Speed");
m2.RowSelector.TopByTimestamp.ShouldBe("Ts");
m2.RowSelector.WhereColumn.ShouldBeNull();
m2.RowSelector.WhereValue.ShouldBeNull();
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys_top_level_and_nested()
{
var json = SqlTagConfigModel
.FromJson(
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts","futureKey":"keep"},"scaling":2.5}""")
.ToJson();
json.ShouldContain("scaling"); // unknown top-level key survives
json.ShouldContain("2.5");
json.ShouldContain("futureKey"); // unknown nested (rowSelector) key survives
json.ShouldContain("keep");
json.ShouldContain("\"topByTimestamp\":\"Ts\"");
}
// ---- Validate() mirrors the parser's accept/reject boundary --------------------------------
[Fact]
public void Validate_returns_null_for_valid_keyValue()
=> SqlTagConfigModel.FromJson(
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
.Validate().ShouldBeNull();
[Fact]
public void Validate_returns_null_for_valid_wideRow_wherePair()
=> SqlTagConfigModel.FromJson(
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"whereColumn":"id","whereValue":"1"}}""")
.Validate().ShouldBeNull();
[Fact]
public void Validate_returns_null_for_valid_wideRow_topByTimestamp()
=> SqlTagConfigModel.FromJson(
"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts"}}""")
.Validate().ShouldBeNull();
[Fact]
public void Validate_rejects_missing_table()
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_keyValue_missing_valueColumn()
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_keyValue_missing_keyValue_field()
=> SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_wideRow_without_a_selector()
=> SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_wideRow_missing_columnName()
=> SqlTagConfigModel.FromJson(
"""{"model":"WideRow","table":"t","rowSelector":{"topByTimestamp":"Ts"}}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_invalid_model_enum()
=> SqlTagConfigModel.FromJson("""{"model":"Bogus","table":"t"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_invalid_type_enum()
=> SqlTagConfigModel.FromJson(
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_deferred_query_model()
=> SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""")
.Validate().ShouldNotBeNull();
// ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader --------------
[Theory]
[InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")]
[InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")]
[InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")]
public void ToJson_output_parses_cleanly_through_SqlEquipmentTagParser(string authored)
{
// Round-trip the authored blob through the editor model, exactly as the TagModal save path does.
var roundTripped = SqlTagConfigModel.FromJson(authored).ToJson();
// The authoritative runtime reader must accept it — this is the editor-output ⇔ parser-input contract.
SqlEquipmentTagParser.TryParse(roundTripped, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Name.ShouldBe("Plant/Sql/dev1/Speed");
def.Table.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public void ToJson_keyValue_output_parses_to_expected_definition_fields()
{
var json = SqlTagConfigModel.FromJson(
"""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")
.ToJson();
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyColumn.ShouldBe("TagName");
def.KeyValue.ShouldBe("Line1.Speed");
def.ValueColumn.ShouldBe("Val");
def.TimestampColumn.ShouldBe("Ts");
def.DeclaredType.ShouldBe(DriverDataType.Float64);
}
[Fact]
public void ToJson_wideRow_wherePair_output_parses_to_expected_definition_fields()
{
var json = SqlTagConfigModel.FromJson(
"""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")
.ToJson();
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.WideRow);
def.ColumnName.ShouldBe("Speed");
def.RowSelectorColumn.ShouldBe("DeviceId");
def.RowSelectorValue.ShouldBe("42");
def.RowSelectorTopByTimestamp.ShouldBeNull();
}
[Fact]
public void ToJson_wideRow_topByTimestamp_output_parses_to_expected_definition_fields()
{
var json = SqlTagConfigModel.FromJson(
"""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")
.ToJson();
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.WideRow);
def.ColumnName.ShouldBe("Speed");
def.RowSelectorTopByTimestamp.ShouldBe("Ts");
def.RowSelectorColumn.ShouldBeNull();
def.RowSelectorValue.ShouldBeNull();
}
}
@@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
@@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
@@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
}, duration: Timeout);
}
/// <summary>
/// Routes a write, retrying until the host accepts it, and fails with the host's own rejection
/// <c>Reason</c> if it never does.
/// </summary>
/// <remarks>
/// <para><b>Why a retry rather than a single Tell.</b> <c>ApplyAck</c> — which
/// <see cref="SpawnHostAndApply"/> waits for — marks the end of the <em>apply</em>, not the point at
/// which a write can succeed. Several things still have to happen after it: the spawned child has to
/// finish <c>InitializeAsync</c> and leave <c>Connecting</c> (that state deliberately fast-fails
/// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that
/// resolves the write. Telling the write immediately therefore races the setup, which is why this
/// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60
/// consecutive runs of this class alone.</para>
/// <para><b>Why retrying does not weaken the assertion.</b> Every rejection branch — the Primary
/// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected
/// fast-fail — replies <em>without</em> reaching the driver. A rejected attempt records nothing, so
/// the caller's <c>Writes.Count.ShouldBe(1)</c> still means exactly what it did before: the accepted
/// write reached the driver exactly once.</para>
/// <para>The failure message carries the last <c>Reason</c> so a genuine regression is diagnosable
/// rather than surfacing as a bare "expected True".</para>
/// </remarks>
/// <param name="actor">The driver-host actor.</param>
/// <param name="nodeId">The ns-qualified NodeId string, as the node manager passes it.</param>
/// <param name="value">The value to write.</param>
/// <param name="realm">The address-space realm the NodeId belongs to.</param>
private void RouteWriteUntilAccepted(
IActorRef actor, string nodeId, object value, AddressSpaceRealm realm)
{
var lastReason = "(no reply)";
AwaitAssert(
() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(1));
lastReason = result.Reason ?? "(none)";
result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}");
},
duration: Timeout,
interval: TimeSpan.FromMilliseconds(50));
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact]

Some files were not shown because too many files have changed in this diff Show More