Compare commits
29 Commits
de036c62aa
...
4e22f02434
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e22f02434 | |||
| 8f0d6ae2cc | |||
| b61fe38823 | |||
| 4e4619514c | |||
| 75a057f268 | |||
| c89da8eb73 | |||
| d54f3c8342 | |||
| bfdf74ec0b | |||
| db295c1439 | |||
| 9675823cd6 | |||
| ec90b7c416 | |||
| e238a71bbc | |||
| 0e3381d599 | |||
| f13a89dbc3 | |||
| b4480bf45a | |||
| 203000a80a | |||
| 429ead837f | |||
| b833dda673 | |||
| 74435801bb | |||
| b97b3a3604 | |||
| b29623cf99 | |||
| 1da770253e | |||
| 3b8b5a77e1 | |||
| 9f17d7efa2 | |||
| 08c1728a98 | |||
| b5e39e1f32 | |||
| d1572a2b29 | |||
| ab54fb013a | |||
| e784545844 |
@@ -89,6 +89,12 @@
|
||||
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
|
||||
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
|
||||
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
<!-- Fully managed PostgreSQL ADO.NET provider for the Sql poll driver's PostgresDialect
|
||||
(NpgsqlFactory.Instance). No native libpq / client install — the same "managed only"
|
||||
constraint that rules out Odbc. Referenced by Driver.Sql.Dialect alongside
|
||||
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
|
||||
native lib/net10.0 TFM. -->
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<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'
|
||||
@@ -97,6 +103,11 @@
|
||||
<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" />
|
||||
<!-- Fully managed ODP.NET Core for the Sql poll driver's OracleDialect
|
||||
(OracleClientFactory.Instance). The ".Core" package is the 100%-managed driver — no Oracle
|
||||
Instant Client / OCI native install, matching the Npgsql constraint above. Latest stable of
|
||||
the 23.x (Oracle 23ai) line, which ships lib/net8.0 + netstandard2.1. -->
|
||||
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.300" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
is pulled transitively at 1.9.0; pin to 1.15.3 (matches the Extensions.Hosting pin below),
|
||||
applied via a direct PackageReference in the Cluster project so the server stack inherits it. -->
|
||||
|
||||
+59
-12
@@ -123,14 +123,21 @@ reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.c
|
||||
|
||||
| Driver | Verdict | Merged | Capabilities | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
|
||||
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
|
||||
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | ~~No `IWritable` **by design** (`:50`).~~ ✅ **`IWritable` added 2026-08-05 (`b29623cf`)** — observation injection via the Agent's `AllowPut` surface, off by default at two gates (`Write.Enabled` + the Agent's own `AllowPut`); MTConnect *Interfaces* stays deferred. No device form. |
|
||||
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) — **and `IWritable` since `72be1922`** | Cleanest of the five: picker + driver form + device form + tag editor all present. ~~8 open follow-up issues.~~ ✅ All eight (#508–#515) **closed 2026-07-31**. |
|
||||
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
|
||||
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
|
||||
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
|
||||
|
||||
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
|
||||
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
|
||||
~~`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
|
||||
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).~~
|
||||
|
||||
✅ **Two of the four gained it since the audit. As of 2026-08-05 `IWritable` is absent from Sql and
|
||||
Calculation only** — and in both that absence is still structural and documented (`SqlDriver.cs:15`).
|
||||
**MQTT** gained it 2026-07-31 (`72be1922`,
|
||||
[#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated — plain `writeTopic`
|
||||
publish + Sparkplug NCMD/DCMD); **MTConnect** gained it 2026-08-05 (`b29623cf` — observation injection
|
||||
through the Agent's `AllowPut` surface, off by default at two independent gates).
|
||||
|
||||
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
|
||||
`catch` filter or a deliberate domain error.
|
||||
@@ -195,14 +202,14 @@ incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
|
||||
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed** — `StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
|
||||
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
|
||||
| **516** ✅ | Driver config edits silently discarded — **RESOLVED `d32d89c3` + `5184a2e1`: seam respawn + in-place re-parse; all 12 drivers now honour a changed config. Stale-open on tracker.** | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
|
||||
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
|
||||
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
|
||||
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
|
||||
| **512** | `.Browser` → `.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
|
||||
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
|
||||
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
|
||||
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
|
||||
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
|
||||
| **515** ✅ | Sparkplug `DataSet`/`Template`/`PropertySet` — **RESOLVED `c74ccc55`: structured metrics flattened to sibling tags; live-gated.** | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
|
||||
| **514** ✅ | Rebirth unarmable on a plant that hasn't birthed — **RESOLVED `7f53bd96`: the browse modal takes a typed `group/edgeNode` scope when no tree scope is armed (edge node only, never a group broadcast).** | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
|
||||
| **513** ✅ | Meaningless `payloadFormat` in Sparkplug blob — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
|
||||
| **512** ✅ | `.Browser` → `.Driver` layering — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
|
||||
| **511** ✅ | `ActAsPrimaryHost` does nothing — **RESOLVED `7f2108dc`: Sparkplug primary-host STATE publishing + Will, Primary-gated; live-gated.** | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
|
||||
| **510** ✅ | `_canRebirth` captured at browse-open — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
|
||||
| **509** ✅ | Metric name with `/` unbrowse-committable — **RESOLVED `7f53bd96`: `RawBrowseCommitMapper.ToRawSegment` sanitises the segment and refuses a collision in words.** | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
|
||||
| **508** ✅ | MQTT write-through — **RESOLVED `72be1922`: `IWritable` shipped (plain `writeTopic` publish + Sparkplug NCMD/DCMD), live-gated 2026-07-31. The evidence column is the audit snapshot; `MqttDriver.cs:66` now names `IWritable`.** | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
|
||||
| **507**→ see above | | | | |
|
||||
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238`→`:540`→`ActorHistorizedTagSubscriptionSink.cs:27-38`→`ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
|
||||
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
|
||||
@@ -1250,6 +1257,46 @@ drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-05 — MTConnect write-back; the MQTT closure records reconciled
|
||||
|
||||
Recorded from branch `feature/mqtt-mtconnect-write`. This is exactly the drift shape the 2026-08-04
|
||||
entry named — **work that closes an item elsewhere leaves the register asserting the item is open** —
|
||||
so it is written back here in the same change as the work.
|
||||
|
||||
**§1.1 corrected on two rows.** `IWritable` is no longer absent from four drivers; **after this branch
|
||||
it is absent from Sql and Calculation only**, and in both that is still structural.
|
||||
|
||||
| Driver | Gained `IWritable` | Commit | Shape |
|
||||
|---|---|---|---|
|
||||
| **MQTT / Sparkplug B** | 2026-07-31 ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated) | `72be1922` | Plain `writeTopic` publish + Sparkplug NCMD/DCMD |
|
||||
| **MTConnect** | 2026-08-05 | `b29623cf` | **Observation injection** through the Agent's optional `AllowPut` surface — off by default at two independent gates (`Write.Enabled` and the Agent's own `AllowPut`), CONDITION items refused |
|
||||
|
||||
⚠️ **MTConnect write-back is not a machine command, and the register should not be read as saying the
|
||||
driver can now command a CNC.** A write sets what the Agent *reports* for a DataItem; the next
|
||||
adapter/SHDR update overwrites it. MTConnect *Interfaces* — the mechanism the original deferral
|
||||
(`2026-07-15-mtconnect-driver-design.md` §3.6) assumed write-back would need — **remains deferred**, and
|
||||
§3.6 now carries a superseded note saying so. Design:
|
||||
`docs/plans/2026-08-05-mtconnect-writeback-design.md`.
|
||||
|
||||
⚠️ **It also does not make any existing node writable.** The OPC UA `CurrentWrite` bit is composed from
|
||||
the authored `Tag.AccessLevel` alone; driver capability is never consulted at compose time. An operator
|
||||
must author the raw tag **ReadWrite**. Browse's new `Operate` stamp on writable leaves is honest
|
||||
labelling only — nothing persists `SecurityClass`.
|
||||
|
||||
**§2's eight MQTT rows (#508–#515) marked ✅ in place** with the commit that resolved each (`72be1922`,
|
||||
`7f53bd96`, `7f2108dc`, `c74ccc55`), following the register's existing convention. Their evidence
|
||||
columns are left as the audit snapshot — #508's `MqttDriver.cs:66 — no IWritable` in particular is now
|
||||
false of the tree, and is annotated as such rather than rewritten.
|
||||
|
||||
**Docs reconciled in the same change** (they contradicted the code, and each other):
|
||||
`docs/drivers/MTConnect.md` (capability surface, new §Write-back, Deferred, the `DeviceName`
|
||||
double-duty note), `docs/drivers/Mqtt.md` (deleted the #514 "empty tree cannot arm a rebirth" known-gap
|
||||
block and the "Read-only. No writes reach the broker" operational note — both contradicted by that
|
||||
file's own closure line), and `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets +
|
||||
§Next actions, which still listed MQTT P3 and both browse gaps as remaining work).
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
*§1–§8 generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
|
||||
|
||||
@@ -286,6 +286,12 @@ services:
|
||||
# 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;"
|
||||
# The Postgres + Oracle counterparts, pointing at the two DISPOSABLE fixture containers on the
|
||||
# shared docker host (tests/.../Driver.Sql.IntegrationTests/Docker/{postgres,oracle}/). Same
|
||||
# committed-dev-secret exception; the passwords are each fixture compose's documented default.
|
||||
# Unlike DevSql these are NOT on the rig's own network, so they carry the host's IP.
|
||||
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
|
||||
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-1"
|
||||
@@ -426,6 +432,10 @@ services:
|
||||
# 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;"
|
||||
# Postgres + Oracle fixture connection strings — see central-1. Both centrals must carry them:
|
||||
# :9200 round-robins, so the AdminUI browse can land on either node.
|
||||
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
|
||||
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-2"
|
||||
|
||||
+53
-5
@@ -87,12 +87,33 @@ on.
|
||||
| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
|
||||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||||
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
||||
|
||||
The first three name a location the driver asks the CNC for on demand.
|
||||
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
|
||||
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
|
||||
traffic. It exists because the fixed tree is the only part of the FOCAS surface
|
||||
with no address of its own, and v3 publishes nodes only for authored tags — so
|
||||
without a form to author, the fixed tree could not be published at all.
|
||||
|
||||
**An address never carries the device.** A tag's device comes from its raw-tag
|
||||
Device row; an address that also named a host would be a second, conflicting
|
||||
routing channel. This is worth stating because it is exactly what went wrong in
|
||||
[#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540): browse leaves
|
||||
advertised `{deviceHost}/{path}` as their reference, the browse-commit wrote that
|
||||
into the address, and every committed tag read `BadNodeIdUnknown` forever.
|
||||
|
||||
Addresses are validated against the per-device `Series` at `InitializeAsync` —
|
||||
a config referencing a number outside the documented range for that series
|
||||
fails at load time with an error message naming the limit. See
|
||||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
|
||||
authoritative range table.
|
||||
authoritative range table. (`FIXED:` paths have no numeric range to check; a
|
||||
path the machine does not expose reads `BadNodeIdUnknown`.)
|
||||
|
||||
An address the parser cannot read is rejected **when you author it** — by the
|
||||
`/raw` tag modal, and by the deploy-time inspector for config arriving via CSV
|
||||
import or the API. It is not a warning you can deploy past into a driver that
|
||||
skips the tag at startup.
|
||||
|
||||
## Backend selection
|
||||
|
||||
@@ -150,7 +171,34 @@ timers without declaring per-address tags.
|
||||
}
|
||||
```
|
||||
|
||||
What gets populated (all under `FOCAS/{deviceHostAddress}/`):
|
||||
`Enabled` defaults to **false** so an upgrade does not grow nodes on configs
|
||||
that never asked for them. You do not have to set it to use the tree: **authoring
|
||||
any `FIXED:` tag arms the poll loop by itself**, because committing those tags
|
||||
already says you want it. The flag remains the way to publish the tree without
|
||||
authoring anything (and the browser turns it on for the duration of a browse).
|
||||
|
||||
### Authoring fixed-tree tags
|
||||
|
||||
Browse the device (`/raw` → device → **Browse device…**, or the tag modal's
|
||||
**Browse machine**) and commit the leaves you want. Each leaf carries its own
|
||||
`FIXED:` address, so the commit produces working tags with no editing. The
|
||||
manual composer next to the browser covers PMC / `PARAM:` / `MACRO:` only —
|
||||
fixed-tree paths are discovered from the machine, not composed from parts.
|
||||
|
||||
Two caveats worth knowing before you browse:
|
||||
|
||||
- **A real CNC can report duplicate sibling names.** One observed Makino returns
|
||||
two axis folders both called `AA`. Committing both is refused at commit time
|
||||
(they would be the same RawPath), and the runtime cache is keyed by the same
|
||||
name — so the `Axes/` subtree on such a machine cannot currently distinguish
|
||||
them. Prefer the non-axis subtrees there until this is resolved.
|
||||
- **Values are dated from the last answered poll, not from the read.** If a CNC
|
||||
stops answering, its fixed-tree values go `BadCommunicationError` rather than
|
||||
continuing to serve the last snapshot as `Good`; the budget is eight poll
|
||||
cadences, floored at 30 s.
|
||||
|
||||
What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
|
||||
`FIXED:{subtree}/{field}`):
|
||||
|
||||
| Subtree | Nodes | Source call |
|
||||
|---------|-------|-------------|
|
||||
@@ -195,9 +243,9 @@ The driver now attempts to auto-scale each axis position using the CNC's `cnc_ge
|
||||
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
|
||||
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
|
||||
|
||||
**Still user-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||||
numbers whose meaning is MTB-specific. Those go under the device folder
|
||||
alongside the fixed subtree.
|
||||
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||||
numbers whose meaning is MTB-specific, and which no browse can discover. Those
|
||||
go under the device folder alongside the fixed subtree.
|
||||
|
||||
## Alarm projection
|
||||
|
||||
|
||||
+120
-15
@@ -15,7 +15,7 @@ fixture recipe read
|
||||
|
||||
## What it talks to
|
||||
|
||||
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
|
||||
A **MTConnect Agent** — the vendor-neutral telemetry endpoint that
|
||||
front-ends a machine tool (or fronts an adapter that itself talks to the
|
||||
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
|
||||
three standard REST paths:
|
||||
@@ -25,8 +25,13 @@ three standard REST paths:
|
||||
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
|
||||
deltas, keyed by a monotonic sequence number
|
||||
|
||||
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
|
||||
because the mainstream Agent surface has no "set value" operation.
|
||||
Those three are the whole data plane: **agent-first** — Discover + Read +
|
||||
Subscribe. There is a fourth path, and it is not part of the data plane: the
|
||||
Agent's optional **`AllowPut`** surface, off by default in `agent.cfg`, which
|
||||
this driver's `IWritable` uses (`b29623cf`, 2026-08-05). It sets what the Agent
|
||||
*reports* for a DataItem; nothing reaches the machine. Read
|
||||
[Write-back](#write-back--observation-injection-off-by-default) before enabling
|
||||
it.
|
||||
|
||||
## Built vs. planned — read this before trusting the design doc's §2
|
||||
|
||||
@@ -68,18 +73,22 @@ below).
|
||||
|
||||
## Capability surface
|
||||
|
||||
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
|
||||
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
|
||||
|
||||
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
|
||||
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
|
||||
(a request/response handshake, not a "set value" operation), and is out of
|
||||
scope for this build — see [Deferred](#deferred-not-in-this-build).
|
||||
`IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut`
|
||||
surface rather than MTConnect *Interfaces*. It is **observation injection, not
|
||||
machine command**, and is off at two independent gates by default — read
|
||||
[Write-back](#write-back--observation-injection-off-by-default) before
|
||||
authoring a writable MTConnect tag. *Interfaces* itself — the protocol's own
|
||||
request/response handshake — is a different mechanism and remains deferred; see
|
||||
[Deferred](#deferred-not-in-this-build).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
|
||||
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
|
||||
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
|
||||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||||
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
@@ -105,6 +114,7 @@ correct and tested, just not on the hot path.
|
||||
"HeartbeatMs": 10000,
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
|
||||
"Write": { "Enabled": false },
|
||||
"Tags": []
|
||||
}
|
||||
}
|
||||
@@ -123,13 +133,14 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
|
||||
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
|
||||
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
|
||||
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
|
||||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||||
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
|
||||
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
|
||||
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
|
||||
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
|
||||
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
|
||||
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
|
||||
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
|
||||
|
||||
@@ -224,6 +235,84 @@ the two need disambiguating, not just OR-ing together). On a changed
|
||||
`/hosts` page — see [Known limitations](#known-limitations) for what that does
|
||||
and does not do.
|
||||
|
||||
## Write-back — observation injection, off by default
|
||||
|
||||
> ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value
|
||||
> lands in the Agent's observation buffer exactly as if an adapter had reported it —
|
||||
> visible to **every** consumer of that Agent, and **overwritten by the next
|
||||
> adapter/SHDR update for that DataItem**. Nothing reaches the CNC. Never document,
|
||||
> script, or operate this as a machine command. It is genuinely useful for
|
||||
> operator-set EVENT values, for external systems feeding DataItems the adapter does
|
||||
> not own, and for test rigs — and nothing else.
|
||||
|
||||
The mechanism is the Agent's **`AllowPut`** surface (`POST
|
||||
{agent}/{device}?{dataItemId}={value}`), a standard cppagent feature that is **off by
|
||||
default**. It is not MTConnect *Interfaces* — see [Deferred](#deferred-not-in-this-build)
|
||||
for why Interfaces was rejected as the write-back shape. Design record:
|
||||
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
||||
§2.2; implementation `b29623cf`.
|
||||
|
||||
### Two independent gates, both off by default
|
||||
|
||||
1. **The Agent** — `AllowPut = true` in `agent.cfg`. Optionally scope it with
|
||||
`AllowPutFrom = <host list>` so only named hosts may write; that list is the Agent's
|
||||
own access control and this driver neither reads nor influences it.
|
||||
2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default
|
||||
`false`; an absent block is disabled).
|
||||
|
||||
Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled`
|
||||
off every slot is refused `BadNotWritable` without an Agent being touched, and with
|
||||
`AllowPut` off the Agent refuses and the driver reports `BadCommunicationError`.
|
||||
|
||||
### The OPC UA write bit is authored, not derived — read this before filing a bug
|
||||
|
||||
**Enabling write-back does not make any existing node writable over OPC UA.** The
|
||||
`CurrentWrite` access bit is composed from the **authored `Tag.AccessLevel`** alone
|
||||
(`AddressSpaceComposer` → `AddressSpaceApplier`, which additionally requires a
|
||||
non-array tag → `OtOpcUaNodeManager.ComposeAccessLevel`); driver capability is never
|
||||
consulted at compose time. An operator must set the raw tag to **ReadWrite** — a
|
||||
browse-committed tag is created `Read` (`RawBrowseCommitMapper.MapLeaf`) whatever the
|
||||
browse said.
|
||||
|
||||
Browse *does* stamp writable leaves `SecurityClassification.Operate` when
|
||||
`Write.Enabled` is on (and `ViewOnly` otherwise, CONDITION always `ViewOnly`), on the
|
||||
same "a node cannot advertise writability the driver would refuse" principle MQTT
|
||||
pinned. But that stamp is **honest labelling, not the access bit**: nothing persists
|
||||
`SecurityClass` — `Tag` has no such column — so it reaches the browse session's
|
||||
attribute side-panel and stops there.
|
||||
|
||||
### What a write does, slot by slot
|
||||
|
||||
`WriteAsync` never throws and one bad slot never fails the batch (matching `ReadAsync`);
|
||||
even cancellation returns a full-length result set. The refusal order is fixed, and each
|
||||
step answers a different operator question:
|
||||
|
||||
| Condition | Status | Meaning |
|
||||
|---|---|---|
|
||||
| `Write.Enabled` off, **or** the DataItem is CONDITION-category | `BadNotWritable` (`0x803B0000`) | Nothing was attempted; no Agent was involved |
|
||||
| No live Agent client (pre-`InitializeAsync`, faulted initialize, post-shutdown) | `BadNotConnected` (`0x808A0000`) | This driver is not running |
|
||||
| The reference resolves to no DataItem, or to one whose owning device cannot be named | `BadNodeIdUnknown` (`0x80340000`) | There is no Agent URL the write could be addressed to |
|
||||
| The value has no invariant string form — `null`, or non-scalar | `BadTypeMismatch` (`0x80740000`) | Arrays are refused rather than rendered (`Convert.ToString` would emit the literal `System.Double[]`, which the Agent stores happily) |
|
||||
| The Agent refused, or could not be reached | `BadCommunicationError` (`0x80050000`) | Includes an `MTConnectError` document returned under HTTP 200 |
|
||||
|
||||
**CONDITION DataItems are excluded deliberately** — they carry alarm semantics and a
|
||||
different `level|text` wire format, and are out of scope for v1.
|
||||
|
||||
**The Agent's refusal text is logged at Warning and never stored.** It stays out of
|
||||
`DriverHealth.LastError`, which is type-only by repo-wide discipline, and a refused write
|
||||
is not evidence the read path degraded — so a refusal leaves driver health untouched.
|
||||
|
||||
**Device resolution needs no extra config.** `TagBinding.WriteTargetsByDataItemId`,
|
||||
built at bind time from a walk of the cached probe model, remembers which device declared
|
||||
each DataItem, so a multi-device Agent is addressed correctly; the fallback is the
|
||||
configured `DeviceName`, and an unresolvable device is `BadNodeIdUnknown` rather than a
|
||||
guess.
|
||||
|
||||
**Values are rendered with `CultureInfo.InvariantCulture`.** A Boolean therefore writes
|
||||
the invariant string `"True"`/`"False"`, **not** `"1"`/`"0"` — if the consuming adapter or
|
||||
Agent vocabulary expects something else, author the tag as a `String` and write the exact
|
||||
token.
|
||||
|
||||
## Browse — free via the universal browser
|
||||
|
||||
MTConnect ships **no bespoke browser project**. Setting
|
||||
@@ -272,6 +361,13 @@ for anything beyond values-and-conditions.
|
||||
driver instance both resolve to the same one Agent connection. Real
|
||||
per-device routing (a client per device) would be a design change, not a
|
||||
bug fix.
|
||||
|
||||
**The write path is the one place a device name is resolved per DataItem**,
|
||||
and it does not use `RawTagEntry.DeviceName` either: it reads the owning
|
||||
device out of the cached probe model and falls back to the top-level
|
||||
`DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)).
|
||||
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
|
||||
reads/browse, fallback write target for writes.
|
||||
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
|
||||
blob's coercion type.** The `/probe`-sourced inference
|
||||
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
|
||||
@@ -294,11 +390,17 @@ for anything beyond values-and-conditions.
|
||||
|
||||
## Deferred (not in this build)
|
||||
|
||||
- **Write-back (MTConnect Interfaces).** Design
|
||||
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
|
||||
the mainstream Agent surface is read-only by design; Interfaces is a rare,
|
||||
optional request/response handshake that would mislead if modelled as
|
||||
`IWritable`. Revisit only on a concrete deployment need.
|
||||
- **MTConnect *Interfaces*.** Still deferred, and for the reason design
|
||||
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1)
|
||||
gave: it is a rare, optional request/response **state-machine handshake**
|
||||
requiring adapter-side cooperation, not a "set value" operation, and would
|
||||
mislead if modelled as `IWritable`.
|
||||
**Write-back itself is no longer deferred** — `b29623cf` shipped `IWritable`
|
||||
over the Agent's `AllowPut` observation-injection surface instead, a different
|
||||
mechanism entirely. See [Write-back](#write-back--observation-injection-off-by-default)
|
||||
and
|
||||
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
||||
§2.1.
|
||||
- **P1.5 fast-follow** (design
|
||||
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
|
||||
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
|
||||
@@ -317,7 +419,10 @@ for anything beyond values-and-conditions.
|
||||
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
|
||||
`UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
|
||||
multipart chunk framing, and ring-buffer re-baseline paging (both the
|
||||
sequence-gap and the `OUT_OF_RANGE` legs).
|
||||
sequence-gap and the `OUT_OF_RANGE` legs). The write path adds
|
||||
`MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200
|
||||
detection) and `MTConnectWriteTests` (`b29623cf` — every refusal leg, the
|
||||
batch shape, the browse-stamping flip).
|
||||
- **Integration tests** —
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
|
||||
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
|
||||
|
||||
+5
-10
@@ -521,6 +521,11 @@ and only** browse action that publishes anything.
|
||||
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
|
||||
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
|
||||
beneath it and is refused **whole** past 32, publishing nothing.
|
||||
- **An empty tree can still arm a rebirth** ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514),
|
||||
fixed `7f53bd96`): when no scope is selected the panel offers a `group/edgeNode` input, so a plant
|
||||
whose edge nodes all birthed before the window opened — the exact case the affordance exists for —
|
||||
is reachable without a tree click. Deliberately an **edge node**, never a group: a typed group
|
||||
broadcast is a self-inflicted broker storm on a large plant.
|
||||
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
|
||||
its consequence; changing the selection disarms it.
|
||||
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
|
||||
@@ -547,14 +552,6 @@ in the rebuilt tree.
|
||||
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
|
||||
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
|
||||
|
||||
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
|
||||
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
|
||||
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
|
||||
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
|
||||
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
|
||||
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
|
||||
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
|
||||
|
||||
## Test Connect
|
||||
|
||||
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
|
||||
@@ -583,8 +580,6 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
|
||||
**rebirth requests only** — never to command a device.
|
||||
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
|
||||
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
|
||||
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
|
||||
|
||||
+22
-5
@@ -12,9 +12,20 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th
|
||||
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
|
||||
`SELECT`s on a timer and turns cells into tag values.
|
||||
|
||||
**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares
|
||||
`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends** —
|
||||
authoring one fails the driver rather than silently falling back.
|
||||
**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
|
||||
(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
|
||||
`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain
|
||||
**reserved names, not working backends** — authoring one fails the driver rather than silently
|
||||
falling back.
|
||||
|
||||
Two provider-specific things are worth knowing before you author against them:
|
||||
|
||||
- **Identifier case folds, and the catalog gate absorbs it.** Postgres lower-cases and Oracle
|
||||
upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own
|
||||
spelling, so an authored `widgets` binds to Oracle's `WIDGETS` rather than failing.
|
||||
- **Oracle `NUMBER` infers as `Float64`.** Column metadata does not carry precision/scale to the
|
||||
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
|
||||
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
|
||||
|
||||
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
|
||||
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
|
||||
@@ -94,7 +105,7 @@ ignoring it later.
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `provider` | `SqlServer` | The only provider v1 constructs. |
|
||||
| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. |
|
||||
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
|
||||
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
|
||||
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
|
||||
@@ -132,7 +143,9 @@ The two classes of authored string are handled differently, and deliberately so:
|
||||
concatenated into command text.
|
||||
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
|
||||
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
|
||||
against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command.
|
||||
against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`**
|
||||
views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and
|
||||
dialect-quoted before they reach a command.
|
||||
|
||||
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
|
||||
legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports
|
||||
@@ -152,6 +165,10 @@ can commit columns as tags. A committed leaf carries **schema, table and column*
|
||||
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
|
||||
typed editor opened empty.
|
||||
|
||||
Browse works against all three constructed providers; the catalog query is the dialect's own
|
||||
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
|
||||
themselves excluded — and the `ALL_*` views on Oracle).
|
||||
|
||||
## Authoring in the AdminUI
|
||||
|
||||
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
|
||||
|
||||
@@ -114,6 +114,21 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
|
||||
### 3.6 `IWritable` — **not implemented (v1)**
|
||||
|
||||
> **SUPERSEDED 2026-08-05 — write-back shipped, by a different mechanism.**
|
||||
> `MTConnectDriver` now implements `IWritable` (`b29623cf`) over the Agent's optional
|
||||
> **`AllowPut`** surface (`POST {agent}/{device}?{dataItemId}={value}`), gated off by
|
||||
> default at both the Agent (`AllowPut`) and the driver (`Write.Enabled`). That is
|
||||
> **observation injection into the Agent's buffer, not machine command** — see
|
||||
> [`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)
|
||||
> §2.2 and `docs/drivers/MTConnect.md` §Write-back.
|
||||
> **The paragraph below is retained because its rationale still holds for *Interfaces*,
|
||||
> which remains deferred** (design §2.1 of the write-back doc re-affirms it: rarely
|
||||
> deployed, needs adapter-side cooperation, a state machine rather than a value write).
|
||||
> Only its two incidental claims about this build are out of date — the driver is no
|
||||
> longer `IWritable`-less, and nodes no longer materialize without `CurrentWrite` *because
|
||||
> of a missing capability*; the `CurrentWrite` bit was in fact always composed from the
|
||||
> authored `Tag.AccessLevel` alone, never from driver capability.
|
||||
|
||||
Justified per research §2.1: the MTConnect Agent surface is read-only by design; write-back exists only via optional, rarely-deployed MTConnect *Interfaces* (a request/response state-machine handshake, not a "set value" — modelling it as `IWritable` would mislead). Capability interfaces are composable, so omitting `IWritable` is idiomatic (Galaxy's write path is even fire-and-forget). Nodes materialize **without** the `AccessLevels.CurrentWrite` bit automatically. Revisit Interfaces only if a concrete deployment needs it.
|
||||
|
||||
### 3.7 `IHostConnectivityProbe` + `IRediscoverable`
|
||||
|
||||
@@ -154,12 +154,12 @@ SQL poll reuses the always-on central SQL Server. **Both shipped.**
|
||||
|
||||
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped. ~~Postgres/ODBC remain deferred to P2/P3 behind the same seam.~~ **Postgres + Oracle shipped 2026-08-05** via managed providers (Npgsql 10.0.3, ODP.NET Core 23.26.300) — dialect-owned parameter markers/bind names + Oracle `BindByName`, `INFORMATION_SCHEMA` vs `ALL_*` catalog SQL, provider gates widened, AdminUI dropdown, env-gated docker suites (postgres:17-alpine / gvenzl/oracle-free:23-slim). The live suites found + fixed ORA-01745 (Oracle rejects reserved-word bind names — `:table` — so the first Oracle build was structurally broken until binds were `p_`-prefixed). MySql/Odbc stay reserved. See [`2026-08-05-sql-postgres-oracle-design.md`](2026-08-05-sql-postgres-oracle-design.md).
|
||||
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
|
||||
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
|
||||
persisted `connectionString` (`1919a8e5`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
|
||||
shipped; Postgres/ODBC deferred behind `ISqlDialect`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server,
|
||||
**PostgreSQL and Oracle** shipped behind `ISqlDialect`; MySql/Odbc reserved).
|
||||
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
|
||||
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
|
||||
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
|
||||
@@ -179,7 +179,8 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
|
||||
limitations** section.
|
||||
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
|
||||
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
|
||||
(P1 shipped deliberately without `IWritable`; **`IWritable` added 2026-08-05, `b29623cf`** — see the
|
||||
Deferred line below), browse **free via the Wave-0 universal
|
||||
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
|
||||
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
|
||||
(both the sequence-gap and `OUT_OF_RANGE` legs).
|
||||
@@ -191,7 +192,18 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
|
||||
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
|
||||
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
|
||||
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
|
||||
- **Write-back — SHIPPED 2026-08-05, not via Interfaces** (`b29623cf`, branch
|
||||
`feature/mqtt-mtconnect-write`; design
|
||||
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)).
|
||||
`IWritable` over the Agent's optional **`AllowPut`** surface
|
||||
(`POST {agent}/{device}?{dataItemId}={value}`) — **observation injection into the Agent's
|
||||
buffer, never a machine command**, gated off by default at both the Agent (`AllowPut`) and the
|
||||
driver (`Write.Enabled`), CONDITION items refused. **MTConnect *Interfaces* remains deferred**
|
||||
— design §3.6's rationale still holds for it (rare, adapter-side cooperation, a state machine
|
||||
rather than a value write), and it is a different mechanism, not this one. Caveat recorded in
|
||||
the driver guide: the OPC UA `CurrentWrite` bit still comes from the authored
|
||||
`Tag.AccessLevel` alone, so an operator must author **ReadWrite**.
|
||||
- **Deferred (documented, not built):** MTConnect *Interfaces* (design §3.6, above); P1.5
|
||||
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
|
||||
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
|
||||
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
|
||||
@@ -210,7 +222,11 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
|
||||
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
|
||||
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
|
||||
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
|
||||
**P3 write-through (`IWritable`) shipped 2026-07-31** — `72be1922`
|
||||
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated): plain
|
||||
`writeTopic` publish + Sparkplug NCMD/DCMD, with a node discovered `ViewOnly` when the encoder
|
||||
would refuse the write. Superseded the "every MQTT node materializes read-only" line this entry
|
||||
carried; see `docs/drivers/Mqtt.md` §Write-through.
|
||||
- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883`
|
||||
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
|
||||
`tests/Drivers/….Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
|
||||
@@ -236,13 +252,31 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
|
||||
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
|
||||
hand-maintained AdminUI surface left behind by a driver-side feature.**
|
||||
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
|
||||
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
|
||||
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
|
||||
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
|
||||
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
|
||||
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
|
||||
previously rendered exactly once at open and never again).
|
||||
- **P2 live gate — four UI gaps recorded, ALL FOUR NOW CLOSED 2026-07-31** (`7f53bd96`; see
|
||||
`docs/drivers/Mqtt.md` §Known gaps, which lists the closures). Recorded as found, with what
|
||||
actually fixed each:
|
||||
- **A completely empty browse tree cannot arm a rebirth**
|
||||
([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)). The scope comes from a tree
|
||||
click, so on a plant that has not birthed since the window opened `Request rebirth…` stays
|
||||
disabled — the very case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets`
|
||||
already accepts a bare `{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to
|
||||
enter one was missing. Mitigation shipped at the time: a **Refresh** button on the browse tree
|
||||
(it re-reads the same session, which previously rendered exactly once at open and never again).
|
||||
**FIXED —** `RawBrowseModal` now offers a `group/edgeNode` scope input whenever no tree scope is
|
||||
armed; edge-node only, never a typed group broadcast.
|
||||
- **A metric name containing `/` cannot be browse-committed**
|
||||
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)). The derived raw **tag Name**
|
||||
is a RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
|
||||
spec-mandatory `Node Control/Rebirth` was refused at commit (`Row N: Name must not contain '/'`).
|
||||
The refusal was loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** was
|
||||
the working path. Fixing it needed a name-sanitisation policy with a collision answer, so it was
|
||||
recorded rather than guessed at. **FIXED —** `RawBrowseCommitMapper.ToRawSegment` sanitises the
|
||||
segment and refuses a collision explicitly.
|
||||
- **`_canRebirth` is captured at browse-open**, so a reaped session still drew the button
|
||||
([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)). **FIXED.**
|
||||
- **The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob**
|
||||
([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) — cosmetic; the factory
|
||||
routes on the Sparkplug tuple and ignored it. **FIXED.**
|
||||
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
|
||||
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
|
||||
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
|
||||
@@ -252,19 +286,14 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
|
||||
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
|
||||
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
|
||||
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
|
||||
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
|
||||
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
|
||||
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
|
||||
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
|
||||
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
|
||||
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
|
||||
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
|
||||
factory routes on the Sparkplug tuple and ignores it.
|
||||
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
|
||||
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
|
||||
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
|
||||
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
|
||||
- **Rediscovery was inert in v3 (platform gap, not MQTT's) — HALF-CLOSED since 2026-07-27.** The
|
||||
Sparkplug driver raises `OnRediscoveryNeeded` on a changed birth metric-set; at the time nothing
|
||||
subscribed to it and `DriverHostActor.HandleDiscoveredNodes` hard-returned. `DriverInstanceActor`
|
||||
now **consumes** the raise (`09a401b8`) and it rides the driver-health snapshot to the AdminUI
|
||||
`/hosts` page as a **re-browse chip**, while the injection path was **deleted** rather than
|
||||
revived (#507, `adce27e7` — its two inputs are structurally empty in v3). So the standing part is
|
||||
unchanged and deliberate: a DBIRTH introducing a metric still does **not** change the served OPC
|
||||
UA tree; an operator re-browses and commits through `/raw`.
|
||||
- **Redundant-pair hazard (documented, not a code change):** both nodes of a pair run the driver, so a
|
||||
**fixed `clientId` makes them evict each other forever** — the broker logs
|
||||
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
|
||||
@@ -283,9 +312,27 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
pass-through) CLOSED 2026-08-05 — see the Wave 0 section above.
|
||||
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
|
||||
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
|
||||
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
|
||||
`28c28667`). **Postgres + Oracle dialects shipped 2026-08-05** (managed providers, see the SQL
|
||||
poll section above); remaining SQL work is optional follow-on — MySql/Odbc dialects behind
|
||||
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
|
||||
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
|
||||
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
|
||||
3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and
|
||||
live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506).
|
||||
Since then:
|
||||
- **MQTT P3 write-through shipped and was live-gated 2026-07-31** — `72be1922`
|
||||
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)): plain `writeTopic` publish
|
||||
+ Sparkplug NCMD/DCMD.
|
||||
- **Both browse-UI gaps are closed**, with the other two P2 records, in `7f53bd96`
|
||||
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509),
|
||||
[#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510),
|
||||
[#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513),
|
||||
[#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)) — see the P2 section above.
|
||||
- **MTConnect write-back is landing on `feature/mqtt-mtconnect-write`** (`b29623cf`), by
|
||||
**observation injection** through the Agent's `AllowPut` surface rather than the Interfaces
|
||||
handshake the original deferral assumed — design
|
||||
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md), which
|
||||
is also the record for this reconciliation. MTConnect *Interfaces* stays deferred.
|
||||
|
||||
Remaining optional follow-on for the pair: MTConnect P1.5 (CONDITION→Part-9 alarms,
|
||||
`TIME_SERIES` arrays, EVENT→enum vocab) and P2 SHDR ingest.
|
||||
|
||||
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# MTConnect write-back + MQTT write-through record reconciliation — design
|
||||
|
||||
**Status:** DONE 2026-08-05 — merged to master (`0e3381d5`); unit suites green (MTConnect 534/534,
|
||||
MQTT 658/658, AdminUI 1064/1064); **live gate PASSED** (§4): the full integration suite ran live
|
||||
against the shared fixture (`10.100.0.35:5000`, AllowPut now on) — 15/15, 0 skipped — covering the
|
||||
write round-trip (value read back from `/current`), the real agent-refusal → `BadCommunicationError`
|
||||
mapping (with the no-`deviceName` → `BadNodeIdUnknown` falsifiability control), and the disabled
|
||||
gate (`BadNotWritable`, agent untouched). The same legs also ran against a local OrbStack fixture
|
||||
during development with agent-log corroboration. **One leg is deliberately deferred to the
|
||||
operator's chrome session:** the docker-dev rig OPC UA leg (author an MTConnect driver with
|
||||
`write.enabled` + a `ReadWrite` tag in AdminUI, deploy, `Client.CLI write`, read back) — authoring
|
||||
requires the AdminUI, and per the operator's instruction chrome-driven gates run separately.
|
||||
Remember the CurrentWrite caveat when running it: the tag must be authored `ReadWrite`
|
||||
(browse-commit defaults to `Read`).
|
||||
|
||||
## 1. Scope correction: the MQTT half of this request is already shipped
|
||||
|
||||
The request ("MQTT P3 write-through plus its two documented browse-UI gaps, MTConnect write-back")
|
||||
came from `docs/plans/2026-07-24-driver-expansion-tracking.md` §Next actions (lines 288–289) — which
|
||||
is **stale**. On master today:
|
||||
|
||||
| Item | Status | Evidence |
|
||||
|---|---|---|
|
||||
| MQTT P3 write-through (`IWritable`: plain `writeTopic` publish + Sparkplug NCMD/DCMD) | **Shipped, live-gated** — #508, `72be1922`, 2026-07-31 | `MqttDriver.cs:65-67` implements `IWritable`; `Mqtt.md:16-20` status banner |
|
||||
| Browse-UI gap 1 (empty tree cannot arm a rebirth) | **Fixed** — #514, `7f53bd96` | `RawBrowseModal.razor:127-157` manual `{group}/{edgeNode}` scope input |
|
||||
| Browse-UI gap 2 (slashed metric name refused at browse-commit) | **Fixed** — #509, `7f53bd96` | `RawBrowseCommitMapper.ToRawSegment` (`:107`), collision refusal (`:117`) |
|
||||
|
||||
So the only MQTT work left is **documentation reconciliation** — three stale records that now
|
||||
contradict the code and `Mqtt.md`'s own closure line (`:597-602`):
|
||||
|
||||
1. `docs/drivers/Mqtt.md:550-556` — still prints the #514 rebirth gap as an open "Known gap".
|
||||
2. `docs/drivers/Mqtt.md:586-587` — "Read-only. No writes reach the broker; every node is ViewOnly"
|
||||
contradicts §Write-through (`:407-480`) in the same file.
|
||||
3. `docs/plans/2026-07-24-driver-expansion-tracking.md:288-289` §Next actions still lists MQTT P3 +
|
||||
the two gaps as remaining work; the P2 section (`:239-263`) still describes the gaps as
|
||||
"documented not fixed".
|
||||
4. `deferment.md:132-133` — "`IWritable` is absent from MTConnect, MQTT, Sql and Calculation" was
|
||||
audited pre-#508 and is wrong about MQTT (and, after this branch, about MTConnect).
|
||||
|
||||
## 2. MTConnect write-back — the real work
|
||||
|
||||
### 2.1 What was deferred, and what mechanism to build on
|
||||
|
||||
The driver shipped deliberately read-only (`MTConnectDriver.cs:63` — no `IWritable`;
|
||||
`docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6). The deferral named **MTConnect
|
||||
Interfaces** (a request/response state-machine handshake between two agents) as the only write-back
|
||||
shape and judged it misleading to model as `IWritable` — "revisit only on a concrete deployment
|
||||
need". This request is that need, but Interfaces is still the wrong mechanism: it is rarely
|
||||
deployed, requires adapter-side cooperation, and is a state machine rather than a value write.
|
||||
|
||||
The mechanism this design uses instead is the **Agent's HTTP PUT/POST surface** — a standard
|
||||
cppagent feature, off by default, enabled by `AllowPut = true` (optionally scoped by
|
||||
`AllowPutFrom = <host list>`) in `agent.cfg`. With it enabled, `PUT`/`POST
|
||||
{agent}/{deviceName}?{dataItemId}={value}` sets the named observation in the agent's buffer exactly
|
||||
as if an adapter had reported it. The repo's own integration fixture already carries the key —
|
||||
currently `AllowPut = false` with a "must not accept writes" comment
|
||||
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg:25-26`).
|
||||
|
||||
### 2.2 Honest semantics — this is observation injection, not machine command
|
||||
|
||||
A PUT write updates **what the agent reports**, not the machine: the value lands in the agent's
|
||||
observation buffer, is visible to every agent consumer, and the next adapter/SHDR update for that
|
||||
data item overwrites it. That is genuinely useful (operator-set EVENT values, external systems
|
||||
feeding data items the adapter does not own, test rigs) but it must never be documented as
|
||||
commanding the CNC. The docs section this branch writes states this prominently, and the feature is
|
||||
**opt-in at two independent layers**: the agent (`AllowPut`) and the driver config
|
||||
(`write.enabled`, default `false`). Either alone leaves today's read-only behaviour untouched.
|
||||
|
||||
### 2.3 Driver design
|
||||
|
||||
**Config.** `MTConnectDriverConfigDto` (+ typed `MTConnectDriverOptions`) gains a `Write` block:
|
||||
|
||||
```json
|
||||
"write": { "enabled": true }
|
||||
```
|
||||
|
||||
Default absent/`false` — nothing changes for existing deployments. No per-tag key: writability is
|
||||
derived (below), matching how the driver derives everything else from the probe model.
|
||||
|
||||
**Capability.** `MTConnectDriver` adds `IWritable`. The factory already returns the concrete type
|
||||
(`MTConnectDriverFactoryExtensions.cs:33-39` documents why), so capability dispatch picks it up
|
||||
with no registry change.
|
||||
|
||||
**`WriteAsync` shape** (mirrors `MqttDriver.cs:670-745` — never throws, batch shape preserved,
|
||||
per-slot codes):
|
||||
|
||||
- `write.enabled == false` → every slot `BadNotWritable` (0x803B0000).
|
||||
- No live client (pre-Initialize / post-Shutdown) → `BadNotConnected`, matching `ReadAsync`'s
|
||||
convention (`MTConnectDriver.cs:628-695`).
|
||||
- RawPath does not resolve via `TagBinding.ToDataItemId` → `BadNodeIdUnknown`.
|
||||
- Data item is CONDITION-category, or the value is null / not representable as an invariant string
|
||||
→ `BadNotWritable` / `BadTypeMismatch` respectively. (Condition PUTs use a different level|text
|
||||
wire format and carry alarm semantics — excluded from v1.)
|
||||
- HTTP call: `POST {agentBase}/{deviceName}?{dataItemId}={value}` via the agent-client seam. The
|
||||
owning `deviceName` comes from a `dataItemId → deviceName` map built at bind time from the cached
|
||||
probe model (falling back to `options.DeviceName`); a multi-device agent is addressed correctly
|
||||
without config.
|
||||
- Agent refusal (non-2xx, or an `MTConnectError` document in a 200 body) → `BadCommunicationError`
|
||||
with the agent's own error text logged (never in `LastError` — type-only discipline).
|
||||
- `OperationCanceledException` fills remaining slots and returns; any other exception → log warning
|
||||
+ `BadCommunicationError`.
|
||||
- Values format with `CultureInfo.InvariantCulture`; booleans as the raw invariant string.
|
||||
|
||||
**Seam extension.** `IMTConnectAgentClient` gains
|
||||
`Task<MTConnectWriteOutcome> WriteObservationAsync(string deviceName, string dataItemId, string value, CancellationToken ct)`
|
||||
(outcome = accepted / agent-error-with-text / transport-error). `MTConnectAgentClient` implements
|
||||
it over `_http` (the unary client with the bounded timeout) using `HttpMethod.Post`; the stub
|
||||
`HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`) covers response-shape unit tests
|
||||
with no socket. The unit-test `CannedAgentClient` records write calls and returns scripted
|
||||
outcomes.
|
||||
|
||||
**Discovery stamping.** `DiscoverAsync` currently stamps every leaf
|
||||
`SecurityClassification.ViewOnly` (`MTConnectDriver.cs:1040-1042`) with a comment saying that is
|
||||
the only honest tier for a driver with no `IWritable`. That derivation becomes conditional:
|
||||
`CanWrite(dataItem)` (= `write.enabled` && category is not CONDITION) → `Operate`, else `ViewOnly`
|
||||
— the same "a node cannot advertise writability the driver would refuse" principle MQTT pinned
|
||||
(`MqttDriver.cs:504`, `CanWrite` at `:625-627`). The implementer must verify how materialization
|
||||
derives the `CurrentWrite` access bit (capability presence + SecurityClass) so a stamped-Operate
|
||||
node is actually writable over OPC UA, and a ViewOnly node is not.
|
||||
|
||||
**Doc comments to update in the same change:** the class remarks
|
||||
(`MTConnectDriver.cs:46-53` "deliberately no IWritable"), the factory remark
|
||||
(`MTConnectDriverFactoryExtensions.cs:39`), and `MTConnectTagDefinition.cs:10-14` ("no
|
||||
corresponding write path").
|
||||
|
||||
### 2.4 Out of scope
|
||||
|
||||
- **MTConnect Interfaces** — still deferred, same rationale as §3.6; this design does not touch it.
|
||||
- Asset writes (`POST /asset`), per-tag write opt-outs, write retry (`isIdempotent` stays `false`
|
||||
at `DriverInstanceActor.cs:776`), and PUT `time=` timestamps — YAGNI.
|
||||
- Any AdminUI surface — the driver form gains nothing; `write.enabled` is authored in the existing
|
||||
driver-config JSON. (No chrome gate needed for this branch.)
|
||||
|
||||
### 2.5 Testing
|
||||
|
||||
1. **Unit (offline, falsifiable).** New `MTConnectWriteTests`: batch contract + every refusal leg
|
||||
via `CannedAgentClient` (disabled-by-default, unknown RawPath, condition category, null value,
|
||||
no client, cancellation fill, agent-error outcome). Encoder-free — the write is a URL, so the
|
||||
URL/verb/device-resolution assertions live in `MTConnectAgentClient` stub-handler tests
|
||||
(MTConnectError-in-200 detection included). Discovery stamping test: same probe fixture browsed
|
||||
with write on/off flips leaf SecurityClass, CONDITION stays ViewOnly.
|
||||
2. **Integration (env-gated, docker fixture).** Flip the fixture to `AllowPut = true` (update the
|
||||
comment to say writes are now in scope) and add a write leg: write via the driver → read
|
||||
`/current` back → the observation carries the written value; a second leg proves an
|
||||
`AllowPut = false`-style refusal maps to `BadCommunicationError` is *not* needed (we cannot run
|
||||
two agent configs in one fixture) — instead a bad-dataItemId write asserts the agent-error
|
||||
mapping. Remove the "(b) Writes" not-covered remark
|
||||
(`MTConnectAgentIntegrationTests.cs:64-65`).
|
||||
3. **Live gate (no chrome).** Against the docker-dev rig + the shared fixture agent: author an
|
||||
MTConnect driver with `write.enabled`, deploy, `Client.CLI write` to a writable node, confirm
|
||||
via `/current` and via OPC UA read-back. Runnable headless by the executing session.
|
||||
|
||||
## 3. Bookkeeping (both halves)
|
||||
|
||||
- `docs/drivers/MTConnect.md`: capability surface (`:71-77`), Deferred (`:295-301`), new
|
||||
§Write-back section with the §2.2 semantics warning.
|
||||
- `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6: superseded note pointing here.
|
||||
- `docs/plans/2026-07-24-driver-expansion-tracking.md`: MQTT P2 gap bullets + §Next actions
|
||||
rewritten to record #508/#509/#514 closure and this branch's MTConnect write-back.
|
||||
- `docs/drivers/Mqtt.md`: delete stale blocks `:550-556` and `:586-587`.
|
||||
- `deferment.md`: correct `:132-133` (post-branch: `IWritable` absent from Sql and Calculation
|
||||
only) and the MTConnect row (`:126`).
|
||||
@@ -0,0 +1,183 @@
|
||||
# MTConnect write-back Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||||
> (opus implementers per user instruction).
|
||||
|
||||
**Goal:** Implement `IWritable` on `MTConnectDriver` via the Agent's HTTP PUT/POST surface
|
||||
(opt-in at agent `AllowPut` AND driver `write.enabled`), and reconcile the stale MQTT
|
||||
write-through/browse-gap records.
|
||||
|
||||
**Architecture:** See `docs/plans/2026-08-05-mtconnect-writeback-design.md` (in this branch).
|
||||
Write = observation injection into the agent buffer, never machine command. Batch-safe
|
||||
`WriteAsync` mirroring `MqttDriver.cs:670-745`; new seam method on `IMTConnectAgentClient`;
|
||||
discovery stamps `Operate` only for leaves the driver would accept.
|
||||
|
||||
**Tech Stack:** .NET 10, xUnit v3 + Shouldly, existing `CannedAgentClient` / stub
|
||||
`HttpMessageHandler` seams. No new packages.
|
||||
|
||||
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/mqtt-mtconnect-write`
|
||||
(branch `feature/mqtt-mtconnect-write`). All paths below relative to it.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Write options block (config + parse)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (config DTO region
|
||||
~:3257-3345 + `ParseOptions` ~:2222)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`,
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
|
||||
|
||||
TDD: failing test first — `{"write":{"enabled":true}}` parses to `Options.Write.Enabled == true`;
|
||||
absent block defaults to `false`; malformed value behaves like the sibling `Probe`/`Reconnect`
|
||||
blocks (match their existing tolerance/failure semantics exactly — read them first). Add
|
||||
`MTConnectWriteOptions` (sealed record, `Enabled`, default false) on `MTConnectDriverOptions`
|
||||
following the `Probe`/`Reconnect` pattern, a nullable `WriteDto` on the config DTO, wire through
|
||||
`ParseOptions`. Commit `feat(mtconnect): parse the write.enabled config block`.
|
||||
|
||||
### Task 1: Agent-client write seam
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs` (new)
|
||||
|
||||
Add to the seam:
|
||||
|
||||
```csharp
|
||||
Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
|
||||
```
|
||||
|
||||
with `MTConnectWriteOutcome` a small readonly record: `Accepted` bool + `AgentError` string?
|
||||
(agent-produced MTConnectError text) — transport failures THROW (HttpRequestException etc.), the
|
||||
driver maps them; agent refusals return `Accepted=false` with the text. Implementation in
|
||||
`MTConnectAgentClient` over `_http` (the bounded-timeout unary client): `HttpMethod.Post` to
|
||||
`{base}/{deviceName}?{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}` built with
|
||||
the same left-part logic as `BuildRequestUri` (:382-403). A 2xx whose body parses as an
|
||||
`MTConnectError` document is a refusal (reuse the LocalName-only XML helpers in
|
||||
`MTConnectXml.cs`); non-2xx is a refusal carrying the status; empty 2xx is accepted.
|
||||
|
||||
TDD via the internal `HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`), the same
|
||||
pattern `MTConnectDriverProbeTests.cs:22-28` uses: assert verb, exact URI (device path + escaped
|
||||
query), accepted on empty 200, refusal on MTConnectError-in-200 (agent text surfaced), refusal on
|
||||
404, throw on handler-thrown transport error. Commit
|
||||
`feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)`.
|
||||
|
||||
### Task 2: `MTConnectDriver` implements `IWritable` + discovery stamping
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (class decl :63,
|
||||
remarks :46-53, WriteAsync new, `TagBinding` device-name map ~:3170-3213, `DiscoverAsync`
|
||||
stamping :1030-1046)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
|
||||
(remark :33-39)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
|
||||
(remark :10-14)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs`
|
||||
(record writes, scripted outcomes)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectWriteTests.cs` (new),
|
||||
`MTConnectDiscoverTests.cs` (stamping cases)
|
||||
|
||||
Per design §2.3. Status-code constants: follow MQTT's named-const pattern
|
||||
(`MqttDriver.cs:76-89`) — `StatusBadNodeIdUnknown 0x80340000`, `StatusBadNotWritable 0x803B0000`,
|
||||
`StatusBadTypeMismatch 0x80740000`, `StatusBadCommunicationError 0x80050000`, plus
|
||||
`BadNotConnected` matching `ReadAsync`'s existing constant. Refusal order per slot: disabled →
|
||||
BadNotWritable; no client → BadNotConnected; unresolved RawPath → BadNodeIdUnknown; CONDITION
|
||||
category → BadNotWritable; null/unrepresentable value → BadTypeMismatch; then the seam call —
|
||||
`Accepted=false` or thrown transport error → BadCommunicationError (log agent text at Warning,
|
||||
never into `LastError`). `OperationCanceledException` fills remaining slots with
|
||||
BadCommunicationError and returns. Never throw. Success = `WriteResult(0u)`.
|
||||
|
||||
Device-name resolution: extend `TagBinding` (or a parallel FrozenDictionary built in
|
||||
`BuildBinding` ~:2887-2980) with `dataItemId → owning deviceName` from the probe model; fall back
|
||||
to `options.DeviceName`; unresolvable device → BadNodeIdUnknown. Values format via
|
||||
`Convert.ToString(value, CultureInfo.InvariantCulture)`.
|
||||
|
||||
Discovery: `CanWrite(dataItem)` = `options.Write.Enabled && !IsCondition(category)` →
|
||||
`SecurityClassification.Operate` else `ViewOnly`; update the :1040-1042 comment. **Verify** how
|
||||
materialization derives `CurrentWrite` (search for how MQTT's Operate-stamped nodes get the write
|
||||
bit — SecurityClass + capability) and state the finding in the commit message; if a server-side
|
||||
gate keys on the driver capability alone, note that existing ViewOnly-committed raw tags keep
|
||||
their authored SecurityClass.
|
||||
|
||||
TDD: every refusal leg observed red first where feasible; the batch-shape test (one bad slot
|
||||
doesn't fail the batch) mirrors `MqttDriverWriteTests.cs:74`. The publish itself is proven by
|
||||
Task 3 live legs, not by asserting a fake was called beyond the seam-call recording (same
|
||||
philosophy as `MqttDriverWriteTests.cs:11-15`). Commit
|
||||
`feat(mtconnect): IWritable via agent observation writes (#468-era deferral closed)`.
|
||||
|
||||
### Task 3: Integration fixture + write legs
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg`
|
||||
(`AllowPut = true` + comment rewrite)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
|
||||
(remove the "(b) Writes" not-covered remark :64-65; add write legs)
|
||||
|
||||
Legs (env-gated by the existing `MTCONNECT_AGENT_ENDPOINT` fixture, `Assert.Skip` clean):
|
||||
1. Driver with `write.enabled` writes a distinct value to a writable EVENT data item (pick one
|
||||
from `Devices.xml` the SHDR adapter does not continuously overwrite — read `adapter.py` to
|
||||
choose; if every item is pumped, add a static EVENT item to `Devices.xml`) → poll `/current`
|
||||
until the observation carries the value (bounded wait, `StreamWait` pattern).
|
||||
2. A write naming a nonexistent dataItemId → `BadCommunicationError` (agent refusal mapping).
|
||||
3. `write.enabled` absent → same write returns `BadNotWritable` without touching the agent.
|
||||
Commit `test(mtconnect): live write legs against the AllowPut fixture`.
|
||||
|
||||
### Task 4: Docs reconciliation (MTConnect + stale MQTT records)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/drivers/MTConnect.md` (capability :71-77, Deferred :295-301, new §Write-back)
|
||||
- Modify: `docs/plans/2026-07-15-mtconnect-driver-design.md` (§3.6 superseded note)
|
||||
- Modify: `docs/drivers/Mqtt.md` (delete stale :550-556 and :586-587)
|
||||
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets :239-263 → closed
|
||||
#509/#510/#513/#514; §Next actions :288-289 rewritten)
|
||||
- Modify: `deferment.md` (:126 MTConnect row, :132-133 four-driver IWritable line → Sql +
|
||||
Calculation only)
|
||||
|
||||
Per design §1 + §3. The Write-back section MUST carry the §2.2 semantics warning (observation
|
||||
injection, adapter overwrites, two-layer opt-in, `AllowPutFrom` pointer). Commit
|
||||
`docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled`.
|
||||
|
||||
### Task 5: Full-suite verification
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors) + full MTConnect unit suite + AdminUI suite +
|
||||
`dotnet test --filter "FullyQualifiedName~Mqtt"` (docs-only but cheap insurance). Integration
|
||||
suite compiles; live legs skip offline. Fix fallout, commit if changes.
|
||||
|
||||
### Task 6: Live gate (main session, headless — NO chrome)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~10 min (main session, after merge)
|
||||
|
||||
Main session, not a subagent: bring up the MTConnect fixture (`ssh dohertj2@10.100.0.35` compose
|
||||
in `/opt/otopcua-mtconnect/` — `lmxopcua-fix sync` equivalent via rsync first, since agent.cfg
|
||||
changed), run the Task 3 legs live (`dotnet test --filter` the new write tests with
|
||||
`MTCONNECT_AGENT_ENDPOINT` set), then the docker-dev rig leg: author + deploy an MTConnect driver
|
||||
with `write.enabled`, `Client.CLI write` a value, read it back via Client.CLI and `/current`.
|
||||
Record results in the design doc §Outcome.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-05-mtconnect-writeback.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 0: Write options block (config + parse)", "status": "completed" },
|
||||
{ "id": 1, "subject": "Task 1: Agent-client write seam", "status": "completed" },
|
||||
{ "id": 2, "subject": "Task 2: MTConnectDriver IWritable + discovery stamping", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 3, "subject": "Task 3: Integration fixture + write legs", "status": "completed", "blockedBy": [2] },
|
||||
{ "id": 4, "subject": "Task 4: Docs reconciliation (MTConnect + stale MQTT records)", "status": "completed", "blockedBy": [2] },
|
||||
{ "id": 5, "subject": "Task 5: Full-suite verification", "status": "completed", "blockedBy": [3, 4] },
|
||||
{ "id": 6, "subject": "Task 6: Live gate (main session, headless)", "status": "completed", "blockedBy": [5], "note": "15/15 live vs shared fixture 2026-08-05; docker-dev rig OPC UA leg deferred to the operator's chrome session (see design doc Status)" }
|
||||
],
|
||||
"lastUpdated": "2026-08-05T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
# Sql driver: Postgres + Oracle providers (managed drivers only) — design
|
||||
|
||||
**Status:** DONE 2026-08-05 — all eight tasks delivered and reviewed. Suites: Driver.Sql.Tests
|
||||
355/355, Browser.Tests 75/75, IntegrationTests 33 skip-clean offline; **both new integration suites
|
||||
also ran LIVE during development** against real local containers (postgres:17-alpine 5/5,
|
||||
gvenzl/oracle-free:23-slim 6/6), which surfaced and fixed a provider-fatal defect no offline test
|
||||
could see: **ORA-01745** — Oracle rejects a bind variable named after a reserved word (`:table`),
|
||||
so every Oracle catalog load failed until OracleDialect namespaced all binds with a `p_` prefix
|
||||
(`ec90b7c4`, pinned by a never-bare-base-name invariant theory). Merged to master `d54f3c83`.
|
||||
|
||||
**Shared-host live run PASSED (2026-08-05, Task 8).** Both fixtures deployed to the shared docker
|
||||
host (`/opt/otopcua-sql-pg` port 15432, `/opt/otopcua-sql-oracle` port 11521, both `project:
|
||||
lmxopcua`-labelled, seeds exited 0) and left running; the env-gated suites ran against them from
|
||||
this repo: **11/11 passed, 0 skipped** (5 Postgres + 6 Oracle — initialize/catalog, KeyValue,
|
||||
WideRow `topByTimestamp`, catalog case-folding, browse walk). Env recipe used:
|
||||
`SQL_PG_ENDPOINT=10.100.0.35:15432 SQL_PG_USER=otopcua SQL_PG_PASSWORD=<compose-up value>` +
|
||||
`SQL_ORACLE_ENDPOINT=10.100.0.35:11521 SQL_ORACLE_USER=otopcua SQL_ORACLE_PASSWORD=<compose-up
|
||||
value>` (service defaults `FREEPDB1`).
|
||||
|
||||
**§4.3's chrome/AdminUI gate PASSED (2026-08-05) — and it found a real defect.** Run against a
|
||||
docker-dev rig rebuilt from master (both centrals on one image, provider assemblies confirmed
|
||||
present in `/app`) with `Sql__ConnectionStrings__FixturePg` / `__FixtureOracle` added to central-1
|
||||
**and** central-2 (`:9200` round-robins, so a browse can land on either node).
|
||||
|
||||
⚠️ **The gate's first finding: Test Connect was broken for BOTH new providers.**
|
||||
`SqlDriverProbe.DefaultDialectFor` was never widened — it still constructed `SqlServerDialect`
|
||||
only and threw `InvalidOperationException` for Postgres/Oracle, so a config that **deployed and
|
||||
browsed fine failed Test Connect**. There are *three* dialect-selection sites and Task 4's `Files:`
|
||||
block named two (`SqlDriverFactoryExtensions`, `SqlDriverBrowser`) plus the enum. No test caught
|
||||
it because **every case in `SqlDriverProbeTests` builds its probe through `ForTest(...)`**, which
|
||||
injects `_ => dialect` and never reaches the production switch — the parameterless constructor had
|
||||
zero coverage. Fixed in `75a057f2`: switch widened arm-for-arm with the factory, plus an internal
|
||||
seam covering the gate directly, a reserved-provider (MySql/Odbc) falsification control, and an
|
||||
end-to-end production-constructor case. Red-first confirmed (3 failures) before the widening;
|
||||
Driver.Sql.Tests then 361/361.
|
||||
|
||||
Legs, after the fix:
|
||||
|
||||
1. **Provider dropdown authoring ✅** — dropdown offers SqlServer/Postgres/Oracle; both saved and
|
||||
persisted as `{"provider":"Postgres",…}` / `{"provider":"Oracle",…}`, i.e. serialized by **name**,
|
||||
not ordinal (the driver-enum-serialization defect class does not recur here).
|
||||
2. **Test-connect ✅** — Postgres `OK · 174 ms`, Oracle `OK · 386 ms`. **Negative control:** the
|
||||
Oracle driver cross-wired to the Postgres connection string returns
|
||||
`FAILED · … (ARGUMENTEXCEPTION)` — the provider itself rejecting the string, and *not*
|
||||
`InvalidOperationException`, which proves the dialect was constructed and the green came from a
|
||||
real round trip rather than an unconditional OK.
|
||||
3. **`/raw` browse + browse-commit ✅** — Postgres browse discovered schema `sqlpoll` → `tag_values`
|
||||
/ `latest_status` → per-column LEAFs; **the schema is owned by `sqlpoll_owner` while the poller
|
||||
connects as `otopcua`**, so this is the live confirmation that `PostgresDialect.ListSchemasSql`
|
||||
sources from `information_schema.tables` rather than `.schemata`. Oracle browse listed the PDB's
|
||||
schemas → `OTOPCUA` → `TAG_VALUES`/`LATEST_STATUS` → LEAFs. One leaf committed per provider,
|
||||
both persisting schema-qualified with the right type:
|
||||
`{"model":"WideRow","table":"sqlpoll.tag_values","columnName":"num_value"}` [Double] and
|
||||
`{"model":"WideRow","table":"OTOPCUA.TAG_VALUES","columnName":"NUM_VALUE"}` [Double].
|
||||
|
||||
## 1. Problem
|
||||
|
||||
`docs/drivers/Sql.md:15-18`: v1 constructs SQL Server only; `SqlProvider` declares `Postgres`,
|
||||
`MySql`, `Odbc`, `Oracle` as reserved names that fail the driver. This branch makes **Postgres and
|
||||
Oracle** working backends via fully managed ADO.NET providers — `Npgsql` and
|
||||
`Oracle.ManagedDataAccess.Core` — no native/thin-client installs. `MySql`/`Odbc` stay reserved.
|
||||
|
||||
The seam already exists and is proven: `ISqlDialect` exposes an abstract `DbProviderFactory`
|
||||
(`ISqlDialect.cs:38`), all runtime I/O goes through `DbConnection`/`DbCommand`/`DbParameter`
|
||||
(`SqlPollReader.cs:379-389`), and the test-only `SqliteDialect` is the standing falsifiability
|
||||
control that the planner emits dialect SQL rather than T-SQL. The concrete gaps:
|
||||
|
||||
1. **Parameter markers are hardcoded `@`** in two places: `SqlGroupPlanner.cs:21-25` (`@k`/`@w`)
|
||||
and the catalog path (`SqlServerDialect.cs:56/:61` SQL text + `SqlCatalogLoader.cs:97-113` bind
|
||||
names). Npgsql happens to accept `@`; Oracle requires `:`.
|
||||
2. **Two provider gates** reject everything but SqlServer: `CreateDialect`
|
||||
(`SqlDriverFactoryExtensions.cs:208-215`) and the browser's `DefaultDialectSelector`
|
||||
(`SqlDriverBrowser.cs:126-127`).
|
||||
3. **Oracle has no `INFORMATION_SCHEMA`** — catalog SQL must come from `ALL_USERS` /
|
||||
`ALL_TABLES` + `ALL_VIEWS` / `ALL_TAB_COLUMNS`; liveness is `SELECT 1 FROM DUAL`; row-limit is
|
||||
`FETCH FIRST 1 ROWS ONLY`.
|
||||
4. **AdminUI** provider dropdown is single-option (`SqlDriverForm.razor:18-25`).
|
||||
5. **Packages** — `Npgsql` and `Oracle.ManagedDataAccess.Core` are absent from
|
||||
`Directory.Packages.props`.
|
||||
|
||||
## 2. Decisions
|
||||
|
||||
- **Both dialects live in the existing `ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` project**, which
|
||||
exists precisely because provider packages are unavoidable there (`.Dialect.csproj:20-26`). Its
|
||||
csproj gains the two `PackageReference`s; consumers get them transitively exactly as
|
||||
`Microsoft.Data.SqlClient` flows today. No new projects, no `.slnx` churn.
|
||||
- **Dialect-owned parameter naming.** `ISqlDialect` gains three members with SqlServer-compatible
|
||||
semantics implemented on every dialect:
|
||||
- `string ParameterMarker(string baseName)` — the token in SQL text (`"@k0"` / `":k0"`).
|
||||
- `string ParameterBindName(string baseName)` — the `DbParameter.ParameterName` value (`"@k0"`
|
||||
for SqlClient/Npgsql; bare `"k0"` for ODP.NET, its documented convention).
|
||||
- `void ConfigureCommand(DbCommand command)` — default no-op; **OracleDialect sets
|
||||
`OracleCommand.BindByName = true`** (ODP.NET defaults to bind-by-position, which would silently
|
||||
mis-bind the planner's multi-parameter `IN` lists — this is the sharpest correctness edge in
|
||||
the branch).
|
||||
`SqlGroupPlanner` drops its `@k`/`@w` consts and takes markers from the dialect (bind names ride
|
||||
`SqlQueryPlan` alongside, keeping `SqlPollReader.cs:398-405`'s copy-don't-derive discipline).
|
||||
`SqlCatalogLoader` binds `schema`/`table` through the dialect instead of the literal `"@schema"`.
|
||||
Every command creation site (`SqlPollReader`, `SqlCatalogLoader`, `SqlDriverProbe`,
|
||||
`SqlBrowseSession`) calls `ConfigureCommand` after `CreateCommand`.
|
||||
- **`PostgresDialect`** (`NpgsqlFactory.Instance`): `@` markers; `"`-quoting with `""` escape, max
|
||||
identifier 63; `LivenessSql = "SELECT 1"`; limit prefix `""` / suffix `" LIMIT 1"`;
|
||||
`INFORMATION_SCHEMA` catalog SQL with `pg_catalog`/`information_schema` schemas excluded and
|
||||
`DefaultSchemaSql = "SELECT current_schema()"`; type map over Postgres names (`integer`,
|
||||
`bigint`, `smallint`, `real`, `double precision`, `numeric`, `boolean`, `text`,
|
||||
`character varying`, `timestamp*`, `uuid`, `bytea`, …).
|
||||
- **`OracleDialect`** (`OracleClientFactory.Instance`): `:` markers, bare bind names, BindByName;
|
||||
`"`-quoting with `""` escape, max identifier 128 (declares Oracle 12.2+ as the supported floor —
|
||||
`FETCH FIRST` needs 12c anyway); `LivenessSql = "SELECT 1 FROM DUAL"`; limit suffix
|
||||
`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL from `ALL_USERS` (schemas),
|
||||
`ALL_TABLES` ∪ `ALL_VIEWS` (tables — matching `INFORMATION_SCHEMA.TABLES`' view inclusion),
|
||||
`ALL_TAB_COLUMNS` (columns), `DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"`;
|
||||
type map over Oracle names — **`NUMBER` maps to `Float64`** (precision/scale are not visible to
|
||||
`MapColumnType(string)`; the per-tag `DeclaredType` override is the documented escape hatch),
|
||||
plus `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`, `DATE`/`TIMESTAMP*`,
|
||||
`CLOB`, `RAW`.
|
||||
- **Case-folding rides the existing catalog gate.** Postgres lower-cases and Oracle upper-cases
|
||||
unquoted identifiers; `SqlCatalogGate` already resolves every authored identifier to the
|
||||
catalog's own spelling, so authored `widgets` binds to Oracle `WIDGETS`. The implementer verifies
|
||||
the gate's comparison is case-insensitive and pins it per-dialect with tests.
|
||||
- **Gates widen to exactly these two.** `CreateDialect` and `DefaultDialectSelector` add
|
||||
`Postgres`/`Oracle`; `MySql`/`Odbc` keep the existing loud rejection (tests updated: the
|
||||
"Postgres is rejected" assertions at `SqlDriverFactoryTests.cs:133-135` and
|
||||
`SqlDriverBrowserTests.cs:188-196` flip to `MySql` as the still-reserved control).
|
||||
- **AdminUI**: `SqlDriverForm.razor` dropdown gains `Postgres` and `Oracle` options; helper text
|
||||
updated. No other UI change — Sql stays a single-connection driver with no device form
|
||||
(`DriverFormMaps.cs:81-89`), `connectionStringRef` + `Sql__ConnectionStrings__<ref>` env
|
||||
resolution is provider-agnostic already, and the `SqlConnectionStringPersisted` deploy gate needs
|
||||
no change (it keys on the literal `connectionString` key, not the provider).
|
||||
- **Packages**: `Directory.Packages.props` gains `Npgsql` (between `MQTTnet` and `Novell.*`) and
|
||||
`Oracle.ManagedDataAccess.Core` (between the `OPCFoundation.*` block and `OpenTelemetry`), latest
|
||||
stable versions on the feed. `CentralPackageTransitivePinningEnabled` is OFF by design, so if
|
||||
restore surfaces an NU1903 in either package's transitive tree, the fix is the established
|
||||
surgical direct-pin pattern (`Directory.Packages.props:118-145`), never a suppress.
|
||||
- **Credential discipline holds automatically**: `SqlDriver` already renders endpoints via the
|
||||
provider-neutral `DbConnectionStringBuilder` reading `host`/`data source` keys
|
||||
(`SqlDriver.cs:47-51, :952-968`) and logs exception types only.
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- `MySql`/`Odbc` dialects, write support (`IWritable` stays structurally absent, `allowWrites`
|
||||
stays inert), the deferred `Query` tag model, connection pooling knobs, and Kerberos/wallet
|
||||
auth schemes (connection-string concerns, owned by the operator's `Sql__ConnectionStrings__*`
|
||||
env value).
|
||||
|
||||
## 4. Testing
|
||||
|
||||
1. **Unit.**
|
||||
- `PostgresDialectTests` / `OracleDialectTests`: quoting (escape doubling, length caps,
|
||||
control-char rejection — mirror `SqlServerDialectTests`), liveness/limit/catalog SQL text,
|
||||
type maps, markers/bind names, and `ConfigureCommand` (Oracle: a real `OracleCommand` has
|
||||
`BindByName == true` after the call; others: no-op).
|
||||
- Planner/loader marker plumbing: a colon-marker fake dialect (or `SqliteDialect` extended)
|
||||
proves the planner emits dialect markers and the reader binds the dialect's bind names —
|
||||
the falsifiability control for gap 1.
|
||||
- Gate tests updated as in §2 (MySql becomes the rejected control).
|
||||
- The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism.
|
||||
2. **Integration (env-gated, skip-clean, docker).** Two new fixtures under
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/`, deployed to the shared
|
||||
docker host per the established pattern:
|
||||
- `postgres:17-alpine` (project `otopcua-sql-pg`, host port 15432) — env
|
||||
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`.
|
||||
- `gvenzl/oracle-free:23-slim` (project `otopcua-sql-oracle`, host port 11521) — env
|
||||
`SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/`SQL_ORACLE_PASSWORD`.
|
||||
Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded
|
||||
schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog),
|
||||
browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider-
|
||||
reference discipline (`.IntegrationTests.csproj:29-33`) — packages arrive transitively.
|
||||
3. **Live `/run` chrome gate — DEFERRED, run separately by the operator.** AdminUI legs (provider
|
||||
dropdown authoring, Test-connect, `/raw` browse against the Postgres and Oracle fixtures,
|
||||
browse-commit of a column). The executing session must NOT run this; it hands the operator the
|
||||
command at the end.
|
||||
|
||||
## 5. Bookkeeping
|
||||
|
||||
`docs/drivers/Sql.md` (§providers table, config table, browse section),
|
||||
`SqlProvider.cs` doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and
|
||||
`docs/research/drivers/sql-poll.md` stays as-is (research is a record, not living doc).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Sql Postgres + Oracle providers Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||||
> (opus implementers per user instruction).
|
||||
|
||||
**Goal:** Make `SqlProvider.Postgres` and `SqlProvider.Oracle` working backends via managed
|
||||
ADO.NET providers (Npgsql, Oracle.ManagedDataAccess.Core).
|
||||
|
||||
**Architecture:** See `docs/plans/2026-08-05-sql-postgres-oracle-design.md` (this branch). Two new
|
||||
dialects in the existing `.Dialect` project; dialect-owned parameter markers/bind-names +
|
||||
`ConfigureCommand` (Oracle BindByName); provider gates widened; AdminUI dropdown; env-gated docker
|
||||
integration suites. Chrome/AdminUI live gate is DEFERRED to the operator.
|
||||
|
||||
**Tech Stack:** .NET 10, central package management (`Directory.Packages.props`), xUnit v3 +
|
||||
Shouldly, SQLite falsifiability control.
|
||||
|
||||
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/sql-multi-provider`
|
||||
(branch `feature/sql-multi-provider`). All paths below relative to it.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Package pins
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj`
|
||||
|
||||
Add `PackageVersion` entries for `Npgsql` (alphabetical slot after `MQTTnet`) and
|
||||
`Oracle.ManagedDataAccess.Core` (after the `OPCFoundation.*` block) — latest stable on the feed
|
||||
(`dotnet package search <id> --exact-match` to confirm; Npgsql 9.x line, ODP.NET Core 23.x line;
|
||||
both must target net8.0+). Add both `PackageReference`s (version-less) to the `.Dialect` csproj
|
||||
under the existing `Microsoft.Data.SqlClient` reference, extending that ItemGroup's
|
||||
rationale comment. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` must come back clean WITH the audit
|
||||
active — if NU1903 fires on a transitive dep, apply the surgical direct-pin pattern documented at
|
||||
`Directory.Packages.props:118-145`, never a suppress. Commit
|
||||
`build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only)`.
|
||||
|
||||
### Task 1: Dialect-owned parameter naming + `ConfigureCommand`
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs` (:21-25 consts,
|
||||
:143, :151, :188)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs` (bind names alongside
|
||||
markers if the plan record needs both)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs` (:379-405 — ConfigureCommand
|
||||
call + bind-name copy)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs` (:97-113, `Bind` :181-188)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs` (ConfigureCommand)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs` (ConfigureCommand)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs` (implement new
|
||||
members; keep linked copy in Browser.Tests compiling)
|
||||
- Test: `SqlGroupPlannerTests.cs`, `SqlPollReaderTests.cs`, `SqlCatalogGateDriverTests.cs` (+ a
|
||||
colon-marker fake dialect in the test tree)
|
||||
|
||||
Per design §2 bullet 2. `ISqlDialect` gains `ParameterMarker(string baseName)`,
|
||||
`ParameterBindName(string baseName)`, `ConfigureCommand(DbCommand)`; SqlServerDialect returns
|
||||
`"@"+name` / `"@"+name` / no-op — **behaviour today must be byte-identical** (existing planner
|
||||
SQL-text assertions must pass untouched; if any needs editing, that is a regression, stop).
|
||||
Planner takes markers from its dialect; catalog SQL text and loader bind names route through the
|
||||
dialect so the two can never disagree. TDD: the colon-marker fake proves markers flow (observed
|
||||
red against the `@k` consts first). Every `CreateCommand` site calls `ConfigureCommand`
|
||||
immediately after creation. Commit
|
||||
`refactor(sql): dialect-owned parameter markers, bind names and command configuration`.
|
||||
|
||||
### Task 2: `PostgresDialect`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs` (new)
|
||||
|
||||
Per design §2 Postgres bullet. `Factory => NpgsqlFactory.Instance`; `Provider =>
|
||||
SqlProvider.Postgres`; `@` markers/bind names; `"`-quoting `""`-escaped, MaxIdentifierLength 63,
|
||||
same control-char/Unicode-Format rejection discipline as `SqlServerDialect.QuoteIdentifier`
|
||||
(:88-114); `LivenessSql "SELECT 1"`; limit `""`/`" LIMIT 1"`; catalog SQL over
|
||||
`information_schema.schemata/tables/columns` excluding `pg_catalog` + `information_schema`,
|
||||
`DefaultSchemaSql "SELECT current_schema()"` (parameter markers in catalog SQL via the Task 1
|
||||
members); `MapColumnType` per design (unknown → the same fallback SqlServerDialect uses — read it
|
||||
first). Tests mirror `SqlServerDialectTests` structure: quoting theory, SQL text, type-map theory,
|
||||
marker members. Commit `feat(sql): PostgresDialect (Npgsql)`.
|
||||
|
||||
### Task 3: `OracleDialect`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 5
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs` (new)
|
||||
|
||||
Per design §2 Oracle bullet. `OracleClientFactory.Instance`; `:` markers, **bare** bind names;
|
||||
`ConfigureCommand` casts to `OracleCommand` and sets `BindByName = true` (the test constructs a
|
||||
real `OracleCommand` and asserts the flag — this is the load-bearing test of the whole branch;
|
||||
also assert a foreign `DbCommand` passes through untouched). Quoting `"`/`""`, max 128;
|
||||
`LivenessSql "SELECT 1 FROM DUAL"`; limit `""`/`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL:
|
||||
`ALL_USERS` schemas, `ALL_TABLES` ∪ `ALL_VIEWS` tables, `ALL_TAB_COLUMNS` columns,
|
||||
`sys_context('userenv','current_schema')` default schema — markers via `:schema`/`:table` through
|
||||
the Task 1 members; `MapColumnType`: `NUMBER`→Float64 (doc the DeclaredType escape hatch in the
|
||||
XML doc comment), `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`/`CLOB`,
|
||||
`DATE`/`TIMESTAMP*`, `RAW`, plus `FLOAT`/`INTEGER` synonyms. Commit
|
||||
`feat(sql): OracleDialect (ODP.NET managed, BindByName)`.
|
||||
|
||||
### Task 4: Widen the provider gates
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs` (:208-215)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs` (:126-127,
|
||||
:146-151 comment)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs` (doc comments)
|
||||
- Test: `SqlDriverFactoryTests.cs` (:133-135 → MySql as rejected control; new: Postgres/Oracle
|
||||
construct with their dialects), `SqlDriverBrowserTests.cs` (:188-196 same flip)
|
||||
|
||||
TDD: red first — author `{"provider":"Postgres"}` and assert construction reaches the
|
||||
connection-string resolver (or dialect type assertion via the binding) instead of the
|
||||
not-available throw. Rejection message updated to name the three supported providers. Commit
|
||||
`feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved`.
|
||||
|
||||
### Task 5: AdminUI dropdown + docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor`
|
||||
(:18-25)
|
||||
- Modify: `docs/drivers/Sql.md` (:15-18, :96 provider row, browse section)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend the existing Sql driver-form
|
||||
model test file (find `SqlDriverFormModelTests` or nearest equivalent) with round-trip of
|
||||
`provider: Postgres` / `Oracle`
|
||||
|
||||
Dropdown gains `Postgres` + `Oracle` options; helper text → "SQL Server, PostgreSQL and Oracle
|
||||
are supported; MySql/Odbc are reserved." Sql.md rewritten per design §5 including the Oracle
|
||||
`NUMBER`→Float64 note and the 12.2+ floor. Commit
|
||||
`feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs`.
|
||||
|
||||
### Task 6: Integration fixtures + env-gated suites
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml`
|
||||
+ `seed-pg.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml`
|
||||
+ `seed-oracle.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs`,
|
||||
`OracleReadTests.cs`
|
||||
|
||||
Per design §4.2. Compose shapes mirror the existing mssql fixture (`Docker/docker-compose.yml`:
|
||||
project label `project: lmxopcua`, one-shot seed service, healthcheck): `postgres:17-alpine`
|
||||
port `15432:5432` env `POSTGRES_PASSWORD=${SQL_PG_PASSWORD}`; `gvenzl/oracle-free:23-slim` port
|
||||
`11521:1521` env `ORACLE_PASSWORD=${SQL_ORACLE_PASSWORD}` (gvenzl images take `APP_USER`/
|
||||
`APP_USER_PASSWORD` for a non-SYS user — use `otopcua`/the password env). Seeds create one
|
||||
KeyValue table + one WideRow table with a timestamp column, plus (Oracle) a deliberately
|
||||
UPPER-CASE table authored lower-case in the test to pin catalog case-folding. Test classes follow
|
||||
`SqlServerReadTests`/`SqlBlackholeTimeoutTests` conventions exactly: env vars
|
||||
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`, `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/
|
||||
`SQL_ORACLE_PASSWORD`; `Assert.SkipWhen` absent; connection strings via each provider's builder
|
||||
obtained FROM THE FACTORY (`dialect.Factory.CreateConnectionStringBuilder()`) so the csproj keeps
|
||||
its no-direct-provider-reference discipline (`.IntegrationTests.csproj:29-33`). Legs per suite:
|
||||
initialize (liveness + catalog), KeyValue read, WideRow topByTimestamp read, browse-session
|
||||
walk, case-folding leg. Commit `test(sql): env-gated Postgres + Oracle integration suites`.
|
||||
|
||||
### Task 7: Full-suite verification + bookkeeping
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors), full Sql unit + Browser.Tests + AdminUI.Tests +
|
||||
Configuration.Tests (the SqlCredentialGuard suites must stay green), integration suites compile +
|
||||
skip clean offline. Update `docs/plans/2026-07-24-driver-expansion-tracking.md` Sql row/deferral
|
||||
note. Commit.
|
||||
|
||||
### Task 8: Fixture deploy + live integration run (main session)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~15 min (main session, after merge)
|
||||
|
||||
Main session, not a subagent: rsync the two fixture dirs to `dohertj2@10.100.0.35:/opt/otopcua-sql-pg/`
|
||||
and `/opt/otopcua-sql-oracle/`, `docker compose up -d` each over ssh, then run
|
||||
`dotnet test --filter "FullyQualifiedName~PostgresRead|FullyQualifiedName~OracleRead"` with the
|
||||
env vars set. (oracle-free first pull is ~1.5 GB — allow time; if the host can't take it, record
|
||||
the suite as authored-but-not-yet-run.) Record results in the design doc §Outcome.
|
||||
|
||||
**Chrome/AdminUI live gate — DEFERRED to the operator.** After merge the operator runs, in a
|
||||
chrome-capable session:
|
||||
`/superpowers-extended-cc:executing-plans docs/plans/2026-08-05-sql-postgres-oracle.md` — gate
|
||||
section: provider dropdown authoring, Test-connect, `/raw` browse + browse-commit against both
|
||||
fixtures at `http://localhost:9200`.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-05-sql-postgres-oracle.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 0: Package pins", "status": "completed" },
|
||||
{ "id": 1, "subject": "Task 1: Dialect-owned parameter naming + ConfigureCommand", "status": "completed" },
|
||||
{ "id": 2, "subject": "Task 2: PostgresDialect", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 3, "subject": "Task 3: OracleDialect", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 4, "subject": "Task 4: Widen the provider gates", "status": "completed", "blockedBy": [2, 3] },
|
||||
{ "id": 5, "subject": "Task 5: AdminUI dropdown + docs", "status": "completed" },
|
||||
{ "id": 6, "subject": "Task 6: Integration fixtures + env-gated suites", "status": "completed", "blockedBy": [4], "note": "found+fixed ORA-01745 (ec90b7c4); both suites ran live locally (pg 5/5, oracle 6/6)" },
|
||||
{ "id": 7, "subject": "Task 7: Full-suite verification + bookkeeping", "status": "completed", "blockedBy": [5, 6] },
|
||||
{ "id": 8, "subject": "Task 8: Fixture deploy + live integration run (main session); chrome gate deferred to operator", "status": "completed", "blockedBy": [7], "note": "shared-host live run 11/11 2026-08-05; chrome/AdminUI gate handed to the operator (design doc Status)" }
|
||||
],
|
||||
"lastUpdated": "2026-08-05T00:00:00Z"
|
||||
}
|
||||
@@ -58,7 +58,8 @@ public static class DriverTypeNames
|
||||
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
|
||||
public const string Mqtt = "Mqtt";
|
||||
|
||||
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
|
||||
/// <summary>MTConnect Agent driver — HTTP/XML over an Agent's probe/current/sample surface, plus
|
||||
/// opt-in observation write-back via the Agent's AllowPut POST surface.</summary>
|
||||
public const string MTConnect = "MTConnect";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.</para>
|
||||
/// <para>
|
||||
/// <b><c>FIXED:</c> is not an area read.</b> The first three forms name a location the driver
|
||||
/// asks the CNC for on demand; a <c>FIXED:</c> path names a field of the per-device fixed-tree
|
||||
/// snapshot the driver's own poll loop already maintains (<c>cnc_sysinfo</c>, <c>cnc_rddynamic2</c>,
|
||||
/// program info, timers, spindle data). It exists because the fixed tree is the only part of the
|
||||
/// FOCAS surface with no address of its own, and v3 publishes nodes only for authored tags — so
|
||||
/// without a form to author, the fixed tree could not be published at all (Gitea #540).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The path carries no device.</b> A tag's device comes from its raw-tag Device row, and an
|
||||
/// address that also named a host would be a second, conflicting routing channel — which is
|
||||
/// precisely the defect #540 reported: the browse leaf's <c>{host}/{path}</c> full reference was
|
||||
/// written into the address field verbatim and then failed to parse, so every browse-committed
|
||||
/// fixed-tree tag read <c>BadNodeIdUnknown</c> forever.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex,
|
||||
string? FixedPath = null)
|
||||
{
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical authored address for a fixed-tree path. The one place the
|
||||
/// <c>FIXED:</c> spelling is constructed, so the browse emitter and the parser cannot drift.
|
||||
/// </summary>
|
||||
/// <param name="path">The fixed-tree path, e.g. <c>Identity/SeriesNumber</c>.</param>
|
||||
/// <returns>The canonical authored address string.</returns>
|
||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// Fixed-tree path. Tested BEFORE the PMC branch on purpose: "F" is a valid PMC letter, so a
|
||||
// FIXED: address entering that branch would be diagnosed as a malformed PMC address rather
|
||||
// than as the fixed-tree address it is. (It would still return null either way — this is
|
||||
// about which rule owns the string, not about the result.)
|
||||
if (src.StartsWith(FixedPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = NormalizeFixedPath(src[FixedPrefix.Length..]);
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a fixed-tree path and returns it, or null when it is not a usable path.
|
||||
/// Accepts <c>Segment[/Segment…]</c> where each segment is non-blank and carries no
|
||||
/// <c>:</c> — a colon would collide with the <c>PARAM:</c>/<c>MACRO:</c>/<c>FIXED:</c>
|
||||
/// prefix grammar and make the address ambiguous to read back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately shape-only: it does NOT check the path against the fixed tree a particular
|
||||
/// CNC actually exposes. What that tree contains is per-device and discovered at runtime
|
||||
/// (spindles, program info and timers are each capability-gated), so a parser that judged
|
||||
/// membership would have to reject addresses that are valid on the machine they were
|
||||
/// authored against. An unknown path resolves to no value at read time, which is where the
|
||||
/// device is known.
|
||||
/// </remarks>
|
||||
private static string? NormalizeFixedPath(string body)
|
||||
{
|
||||
var path = body.Trim();
|
||||
if (path.Length == 0) return null;
|
||||
if (path.Contains(':', StringComparison.Ordinal)) return null;
|
||||
if (path[0] == '/' || path[^1] == '/') return null;
|
||||
|
||||
foreach (var segment in path.Split('/'))
|
||||
if (string.IsNullOrWhiteSpace(segment)) return null;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
/// <summary>PMC area — letter + byte + optional bit (<c>X0.0</c>, <c>R100</c>).</summary>
|
||||
Pmc,
|
||||
|
||||
/// <summary>CNC parameter number (<c>PARAM:1020</c>, <c>PARAM:1815/0</c>).</summary>
|
||||
Parameter,
|
||||
|
||||
/// <summary>Macro variable number (<c>MACRO:100</c>).</summary>
|
||||
Macro,
|
||||
|
||||
/// <summary>
|
||||
/// A logical path into the driver's per-device fixed tree (<c>FIXED:Identity/SeriesNumber</c>).
|
||||
/// Served from the fixed-tree poll snapshot, never from an area read — and read-only, because
|
||||
/// nothing in that tree is a writable location on the CNC.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
}
|
||||
@@ -117,6 +117,21 @@ public static class FocasTagDefinitionFactory
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads
|
||||
// BadNodeIdUnknown forever, while driver health stays Healthy with zero errors. The
|
||||
// AdminUI modal rejects it outright; this covers the paths that never touch the modal
|
||||
// (CSV import, the API) by at least surfacing it on the deployment.
|
||||
if (root.TryGetProperty("address", out var addr)
|
||||
&& addr.ValueKind == JsonValueKind.String
|
||||
&& addr.GetString() is { } address
|
||||
&& !string.IsNullOrWhiteSpace(address)
|
||||
&& FocasAddress.TryParse(address) is null)
|
||||
{
|
||||
warnings.Add(
|
||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
{
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Documented-API capability matrix — per CNC series, what ranges each
|
||||
/// Documented-API capability matrix — per CNC series, what ranges each addressed
|
||||
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
|
||||
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
|
||||
/// </summary>
|
||||
@@ -35,6 +35,10 @@ public static class FocasCapabilityMatrix
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
// Fixed-tree paths are not area reads, so there is no per-series numeric range to check.
|
||||
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||
FocasAreaKind.Fixed => null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// produced equipment tags are encountered for the first time.
|
||||
private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>
|
||||
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
|
||||
/// serves fixed-tree values. Computed at <see cref="InitializeAsync"/> from the config flag
|
||||
/// OR the presence of at least one authored <see cref="FocasAreaKind.Fixed"/> tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The OR is load-bearing.</b> <c>FocasFixedTreeOptions.Enabled</c> defaults to
|
||||
/// <see langword="false"/> so an upgrade does not grow nodes on configs that never asked for
|
||||
/// them, and the universal browser force-enables it for the duration of a browse only. An
|
||||
/// operator therefore browses the fixed tree, commits leaves from it, deploys — and lands on
|
||||
/// a driver whose loop never starts, so every one of those tags reads Bad with a correct
|
||||
/// address. Authoring a <c>FIXED:</c> tag IS the request for the tree; the config flag stays
|
||||
/// as the way to publish it without authoring anything.
|
||||
/// </remarks>
|
||||
private bool _fixedTreeActive;
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
||||
@@ -248,7 +263,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
||||
|
||||
if (_options.FixedTree.Enabled)
|
||||
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
||||
// config flag defaults off and the operator who committed those tags has already said
|
||||
// what they want.
|
||||
_fixedTreeActive = _options.FixedTree.Enabled
|
||||
|| _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
|
||||
if (_fixedTreeActive)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
@@ -306,6 +326,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByRawPath.Clear();
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_parsedAddressesByTagName.Clear();
|
||||
_fixedTreeActive = false;
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
||||
}
|
||||
|
||||
@@ -377,7 +398,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
|
||||
// already fires them on cadence.
|
||||
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
|
||||
//
|
||||
// This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored
|
||||
// tags do not take it — their reference is a RawPath — and are served further down by
|
||||
// their FIXED: address instead (Gitea #540).
|
||||
if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx)
|
||||
{
|
||||
results[i] = fx;
|
||||
continue;
|
||||
@@ -396,8 +421,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
|
||||
// A FIXED: tag names a field of this device's fixed-tree snapshot, which the
|
||||
// fixed-tree loop already maintains — there is no area read to issue, and no
|
||||
// connection to force. A null here means the path resolves to nothing on THIS
|
||||
// device (an unsupported capability, a renamed axis, a typo), which is a genuine
|
||||
// BadNodeIdUnknown; a device that has not answered yet reports a comms fault
|
||||
// instead, because the address may well be fine.
|
||||
if (parsed.Kind == FocasAreaKind.Fixed)
|
||||
{
|
||||
results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now)
|
||||
?? new DataValueSnapshot(
|
||||
null,
|
||||
device.LastFixedPollUtc is null
|
||||
? FocasStatusMapper.BadCommunicationError
|
||||
: FocasStatusMapper.BadNodeIdUnknown,
|
||||
null,
|
||||
now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
@@ -459,8 +504,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
|
||||
// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of
|
||||
// machine state (identity, positions, program, timers). Refuse before opening a
|
||||
// connection rather than sending a write the wire would reject anyway.
|
||||
if (parsed.Kind == FocasAreaKind.Fixed)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
@@ -525,31 +580,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
|
||||
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
|
||||
if (_options.FixedTree.Enabled
|
||||
if (_fixedTreeActive
|
||||
&& _devices.TryGetValue(device.HostAddress, out var state)
|
||||
&& state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var identity = deviceFolder.Folder("Identity", "Identity");
|
||||
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "AxisCount", FocasDriverDataType.Int32);
|
||||
|
||||
var axesFolder = deviceFolder.Folder("Axes", "Axes");
|
||||
foreach (var axis in cache.Axes)
|
||||
{
|
||||
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "DistanceToGo");
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "ServoLoad");
|
||||
}
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
|
||||
EmitAxisVariable(axesFolder, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, "SpindleSpeed", "Actual");
|
||||
|
||||
// Spindle subtree — one folder per discovered spindle, suppressed
|
||||
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
||||
@@ -563,9 +618,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
var spindleFolder = spindleRoot.Folder(name, name);
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,26 +628,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (cache.Capabilities.ProgramInfo)
|
||||
{
|
||||
var program = deviceFolder.Folder("Program", "Program");
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "BlockCount", DriverDataType.Int32);
|
||||
|
||||
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
|
||||
EmitFixedVariable(opMode, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
var timers = deviceFolder.Folder("Timers", "Timers");
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,10 +677,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private enum FocasDriverDataType { String, Int32, Float64 }
|
||||
|
||||
/// <summary>
|
||||
/// Emit one <c>Identity</c> leaf.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The emitted <c>FullName</c> is the authorable address, not the runtime cache key.</b>
|
||||
/// A captured browse leaf's id IS its <c>FullName</c>, and that string is what both the
|
||||
/// <c>/raw</c> browse-commit mapper and the manual picker's tree-select write into the tag's
|
||||
/// <c>TagConfig.address</c>. Emitting the host-prefixed cache key therefore authored an
|
||||
/// address no FOCAS parser accepts: the driver skipped every such tag at init and each one
|
||||
/// read <c>BadNodeIdUnknown</c> forever, while the deploy sealed green and driver health
|
||||
/// stayed Healthy (Gitea #540).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host is dropped rather than stripped later because a tag's device is already carried
|
||||
/// by its raw-tag Device row. Two routing channels for one fact is what created the defect;
|
||||
/// one channel is the fix. <see cref="FixedTreeReference"/> keeps the host for the internal
|
||||
/// per-device caches, which do need to distinguish devices.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="field">The identity field name.</param>
|
||||
/// <param name="type">The field's data type.</param>
|
||||
private static void EmitIdentityVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
|
||||
IAddressSpaceBuilder folder, string field, FocasDriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"Identity/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type switch
|
||||
@@ -643,10 +721,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
NativeDataType: NativeName(type)));
|
||||
}
|
||||
|
||||
/// <summary>Emit one <c>Axes</c> leaf. See <see cref="EmitIdentityVariable"/> on the address shape.</summary>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="axisName">The axis display name.</param>
|
||||
/// <param name="field">The per-axis field name.</param>
|
||||
private static void EmitAxisVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
|
||||
IAddressSpaceBuilder folder, string axisName, string field)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"Axes/{axisName}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
@@ -675,13 +757,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>
|
||||
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
||||
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
||||
/// …). Address shape is <c>FIXED:{folderPath}/{field}</c> — see
|
||||
/// <see cref="EmitIdentityVariable"/>.
|
||||
/// </summary>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="folderPath">The fixed-tree folder path.</param>
|
||||
/// <param name="field">The field name.</param>
|
||||
/// <param name="type">The field's data type.</param>
|
||||
private static void EmitFixedVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
|
||||
string field, DriverDataType type)
|
||||
IAddressSpaceBuilder folder, string folderPath, string field, DriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"{folderPath}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type,
|
||||
@@ -700,14 +786,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
|
||||
/// Canonical <b>runtime cache key</b> for a fixed-tree node. Keeps the device
|
||||
/// host as a prefix so multi-device configs don't collide, and the rest is
|
||||
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
||||
/// what <see cref="ReadAsync"/> looks up.
|
||||
/// what <see cref="TryReadFixedTreeOnDevice"/> looks up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is an internal key, not an authorable address</b> — see
|
||||
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings
|
||||
/// (Gitea #540).
|
||||
/// </remarks>
|
||||
/// <param name="deviceHost">The host address of the device.</param>
|
||||
/// <param name="path">The path within the fixed tree.</param>
|
||||
/// <returns>The canonical full reference for the node.</returns>
|
||||
/// <returns>The canonical cache key for the node.</returns>
|
||||
internal static string FixedTreeReference(string deviceHost, string path) =>
|
||||
$"{deviceHost}/{path}";
|
||||
|
||||
@@ -823,6 +914,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
||||
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
|
||||
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
|
||||
// succeeds would serve a perfectly valid sysinfo as stale.
|
||||
state.LastFixedPollUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch (Exception ex)
|
||||
@@ -872,6 +967,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
|
||||
// The device answered this tick. Stamped after the axis pass rather than at the top
|
||||
// of the loop: EnsureConnectedAsync can hand back a cached handle without any wire
|
||||
// traffic, so it alone does not prove the CNC is still there.
|
||||
state.LastFixedPollUtc = DateTime.UtcNow;
|
||||
|
||||
// Servo loads + spindle loads — both return bulk arrays, so folding
|
||||
// into the axis cadence is cheap. Each is gated by the bootstrap
|
||||
// capability probe — unsupported on this series = silent skip.
|
||||
@@ -1054,79 +1154,119 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
||||
return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);
|
||||
if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit)
|
||||
return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
||||
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
/// <summary>
|
||||
/// How long after a device's last answered fixed-tree poll its cached values stop being
|
||||
/// served as Good. Eight axis-poll cadences, floored at 30 s so the sub-second default
|
||||
/// (250 ms) does not make an ordinarily slow CNC flap.
|
||||
/// </summary>
|
||||
/// <param name="pollInterval">The configured fixed-tree poll interval.</param>
|
||||
/// <returns>The staleness budget.</returns>
|
||||
internal static TimeSpan FixedTreeStaleAfter(TimeSpan pollInterval) =>
|
||||
TimeSpan.FromTicks(Math.Max(pollInterval.Ticks * 8, TimeSpan.FromSeconds(30).Ticks));
|
||||
|
||||
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
/// <summary>
|
||||
/// Resolves one fixed-tree <paramref name="path"/> (<c>Identity/SeriesNumber</c>,
|
||||
/// <c>Axes/X/AbsolutePosition</c>, …) against a single device's caches. Returns null when
|
||||
/// this device's fixed tree has no such path — the caller decides what that means.
|
||||
/// </summary>
|
||||
/// <param name="state">The device whose caches to read.</param>
|
||||
/// <param name="path">The fixed-tree path, with no device prefix.</param>
|
||||
/// <param name="now">The read timestamp.</param>
|
||||
/// <returns>The snapshot, or null when the path names nothing on this device.</returns>
|
||||
/// <remarks>
|
||||
/// Values are dated from <see cref="DeviceState.LastFixedPollUtc"/>, not from
|
||||
/// <paramref name="now"/>, and go <c>BadCommunicationError</c> once that stamp is older than
|
||||
/// <see cref="FixedTreeStaleAfter"/>. A cache hit deliberately does NOT advance driver health:
|
||||
/// the value came from the poll loop, not from this call, and crediting it would let a driver
|
||||
/// serving nothing but fixed-tree tags report a fresh successful read while its CNC was gone.
|
||||
/// </remarks>
|
||||
private DataValueSnapshot? TryReadFixedTreeOnDevice(DeviceState state, string path, DateTime now)
|
||||
{
|
||||
var polledUtc = state.LastFixedPollUtc;
|
||||
if (polledUtc is null) return null;
|
||||
var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval)
|
||||
? FocasStatusMapper.BadCommunicationError
|
||||
: FocasStatusMapper.Good;
|
||||
var sourceUtc = polledUtc.Value;
|
||||
|
||||
if (state.LastFixedSnapshots.TryGetValue(
|
||||
FixedTreeReference(state.Options.HostAddress, path), out var raw))
|
||||
return new DataValueSnapshot(raw, status, sourceUtc, now);
|
||||
|
||||
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
|
||||
var suffixFull = path;
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, status, sourceUtc, now);
|
||||
}
|
||||
|
||||
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
{
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
||||
}
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var value = path switch
|
||||
{
|
||||
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
var value = suffix switch
|
||||
{
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1357,6 +1497,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time the fixed-tree loop last got an answer out of this device
|
||||
/// — set when the bootstrap completes and again after each poll tick's axis pass. Null
|
||||
/// until the device has answered once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is what stops a dead CNC publishing its last values as Good forever.</b> The
|
||||
/// fixed-tree poll loop swallows a failed tick (LogDebug + retry) by design, so a device
|
||||
/// that stops answering leaves the caches populated and unchanging with nothing marking
|
||||
/// them stale. That was harmless while nothing could bind to the fixed tree; Gitea #540
|
||||
/// makes those nodes authorable, so reads are now dated from this stamp and go Bad once
|
||||
/// it falls outside <see cref="FixedTreeStaleAfter"/>.
|
||||
/// </remarks>
|
||||
public DateTime? LastFixedPollUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
|
||||
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
|
||||
|
||||
@@ -109,6 +109,12 @@ public sealed class MTConnectDriverOptions
|
||||
/// <c>/sample</c> streaming connection.
|
||||
/// </summary>
|
||||
public MTConnectReconnectOptions Reconnect { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Write-back settings. Off by default — the driver shipped read-only, and enabling this is
|
||||
/// only half the gate: the Agent's own <c>AllowPut</c> must be on too.
|
||||
/// </summary>
|
||||
public MTConnectWriteOptions Write { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
|
||||
@@ -132,3 +138,16 @@ public sealed class MTConnectReconnectOptions
|
||||
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
|
||||
public double BackoffMultiplier { get; init; } = 2.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-back knobs. A write is <b>observation injection</b> via the Agent's <c>AllowPut</c>
|
||||
/// surface — it sets what the Agent reports for a DataItem, not the machine itself (design §2.2).
|
||||
/// </summary>
|
||||
public sealed class MTConnectWriteOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether write-back is enabled. Default <c>false</c> — an existing
|
||||
/// deployment that never authors this block keeps today's read-only behaviour exactly.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
|
||||
/// </summary>
|
||||
/// <param name="FullName">
|
||||
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
|
||||
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
|
||||
/// there is no corresponding write path.
|
||||
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is the
|
||||
/// driver's reference for read, subscribe <b>and write</b> — the write being an <i>observation
|
||||
/// injection</i> through the Agent's <c>AllowPut</c> surface
|
||||
/// (<c>POST {agent}/{device}?{id}={value}</c>), which sets what the Agent reports for the DataItem
|
||||
/// rather than commanding the machine. It is off unless both the Agent's <c>AllowPut</c> and the
|
||||
/// driver's <c>write.enabled</c> say otherwise, and CONDITION-category items are excluded.
|
||||
/// </param>
|
||||
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
|
||||
/// <param name="IsArray">
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The result of a <see cref="IMTConnectAgentClient.WriteObservationAsync"/> call, as the
|
||||
/// <b>Agent</b> answered it. A transport failure is never represented here — it throws (see the
|
||||
/// method's contract), so an outcome always means "the Agent replied, and this is what it said".
|
||||
/// </summary>
|
||||
/// <param name="Accepted">
|
||||
/// <c>true</c> when the Agent took the observation: a 2xx response whose body is not an
|
||||
/// <c>MTConnectError</c> document. <c>false</c> is a refusal, and
|
||||
/// <see cref="AgentError"/> then always carries text.
|
||||
/// </param>
|
||||
/// <param name="AgentError">
|
||||
/// Why the Agent refused, in its own words where it supplied any — the joined
|
||||
/// <c>errorCode: text</c> of the <c>MTConnectError</c> document, or the HTTP status line for a
|
||||
/// non-2xx answer. <c>null</c> if and only if <see cref="Accepted"/> is <c>true</c>.
|
||||
/// <para>
|
||||
/// This is Agent-authored text destined for a <b>log line</b>, never for a driver's
|
||||
/// <c>LastError</c> (which is type-only by repo-wide discipline) and never for an OPC UA
|
||||
/// status code's own message.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
|
||||
|
||||
/// <summary>
|
||||
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
|
||||
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
|
||||
@@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
|
||||
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
|
||||
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
|
||||
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
|
||||
/// needs to implement these four instance members; <see cref="IsSequenceGap"/> is a static
|
||||
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
||||
/// against this seam never has to reference the concrete client.
|
||||
/// </para>
|
||||
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
|
||||
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is observation injection, not machine command.</b> The value lands in the
|
||||
/// Agent's observation buffer exactly as if an adapter had reported it — every consumer
|
||||
/// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem
|
||||
/// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both
|
||||
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and the driver's
|
||||
/// <c>write.enabled</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A refusal is a return value; a transport failure is an exception.</b> The split is
|
||||
/// the whole point of the outcome type, and it is what lets the driver map the two to
|
||||
/// different OPC UA status codes without inspecting exception text:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// The Agent answered and <b>took</b> the observation (2xx, body empty or not an
|
||||
/// <c>MTConnectError</c>) ⇒ <see cref="MTConnectWriteOutcome.Accepted"/>. An
|
||||
/// <b>empty 2xx body is acceptance</b> — that is what a cppagent actually sends,
|
||||
/// so treating a body-less answer as suspect would refuse every successful write.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The Agent answered and <b>refused</b> — a non-2xx status, or an
|
||||
/// <c>MTConnectError</c> document served (as Agents do) under <b>HTTP 200</b> ⇒
|
||||
/// not accepted, with the Agent's own text in
|
||||
/// <see cref="MTConnectWriteOutcome.AgentError"/>. The 200-with-error shape is
|
||||
/// why the status code alone can never decide this.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The Agent could not be <b>reached or did not answer in time</b> ⇒ throws
|
||||
/// (<see cref="HttpRequestException"/>, or a <see cref="TimeoutException"/> for
|
||||
/// the per-call deadline; <see cref="OperationCanceledException"/> when
|
||||
/// <paramref name="cancellationToken"/> is cancelled or the client is disposed).
|
||||
/// Folding these into a <c>false</c> outcome would make "the machine's Agent is
|
||||
/// unreachable" indistinguishable from "the Agent rejected this DataItem" —
|
||||
/// identical to the caller, opposite in what an operator must do about it.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The write is a URL, so escaping is this seam's job, not the caller's.</b>
|
||||
/// <paramref name="dataItemId"/> and <paramref name="value"/> are passed <i>unescaped</i>
|
||||
/// and are percent-encoded by the implementation. A caller that pre-escaped would
|
||||
/// double-encode and write a literal <c>%20</c> into the Agent's buffer, which the Agent
|
||||
/// accepts — so the corruption is silent and shows up only as a wrong value on read-back.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="deviceName">
|
||||
/// The device that owns the DataItem — the path segment of the request. Required even on a
|
||||
/// single-device Agent, because the Agent's PUT surface is addressed per device.
|
||||
/// </param>
|
||||
/// <param name="dataItemId">The DataItem's <c>id</c>, as declared by <c>/probe</c>. Unescaped.</param>
|
||||
/// <param name="value">The value to report, already rendered to its invariant string form. Unescaped.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What the Agent said — see the remarks.</returns>
|
||||
Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
||||
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
private readonly ILogger? _logger;
|
||||
private readonly Uri _probeUri;
|
||||
private readonly Uri _currentUri;
|
||||
private readonly string _agentRoot;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
@@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// Agent and an idle one both emit at least one chunk per heartbeat.
|
||||
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
||||
|
||||
_agentRoot = ResolveAgentRoot(options);
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
||||
@@ -242,6 +246,130 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Issued on <c>_http</c>, the <b>unary</b> client — the write is a short request/response
|
||||
/// and must be bounded by <c>RequestTimeoutMs</c> like the other unary legs. Sending it on
|
||||
/// the streaming client would inherit that client's infinite timeout, and a frozen Agent
|
||||
/// would park a write forever with nothing to bound it (the streaming leg's heartbeat
|
||||
/// watchdog protects a multipart body, not a one-shot POST).
|
||||
/// </remarks>
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var uri = BuildWriteUri(deviceName, dataItemId, value);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
string body;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
// Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller
|
||||
// needs it as a value it can map to a per-tag status code rather than as an exception
|
||||
// indistinguishable from the Agent being unreachable.
|
||||
var agentError = DescribeAgentError(body);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd();
|
||||
|
||||
return new MTConnectWriteOutcome(
|
||||
Accepted: false, agentError is null ? status : $"{status}: {agentError}");
|
||||
}
|
||||
|
||||
// An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status
|
||||
// code alone would report it as a success.
|
||||
return agentError is null
|
||||
? new MTConnectWriteOutcome(Accepted: true, AgentError: null)
|
||||
: new MTConnectWriteOutcome(Accepted: false, agentError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's <c>AllowPut</c> URL: the device as a path segment, the DataItem id and value
|
||||
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
|
||||
/// contract for why escaping cannot be the caller's job.
|
||||
/// </summary>
|
||||
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
|
||||
{
|
||||
var device = Uri.EscapeDataString(deviceName.Trim());
|
||||
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
|
||||
|
||||
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's own refusal text when <paramref name="body"/> is an <c>MTConnectError</c>
|
||||
/// document, else <c>null</c> (including for an empty body, a non-XML body, and any other
|
||||
/// MTConnect document).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read by <see cref="XName.LocalName"/> only, like every other document this driver parses:
|
||||
/// the namespace URI carries the MTConnect version, so a qualified match would silently stop
|
||||
/// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a
|
||||
/// successful write. The error-text walk itself is
|
||||
/// <see cref="MTConnectXml.ExtractErrorText"/>, shared with the read paths'
|
||||
/// <see cref="MTConnectXml.DescribeUnexpectedRoot"/> so the two cannot drift on what counts
|
||||
/// as an Agent objection.
|
||||
/// </remarks>
|
||||
private static string? DescribeAgentError(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
XElement? root;
|
||||
try
|
||||
{
|
||||
root = XDocument.Parse(body).Root;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
// Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not
|
||||
// refused anything, so this is not the place to invent a failure.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// An error document with no readable detail is still a refusal, so the shared helper's null
|
||||
// — which means "nothing readable in here", NOT "this was not an error" — must become text
|
||||
// rather than being passed on. Returning it as null would turn the refusal into acceptance.
|
||||
return MTConnectXml.ExtractErrorText(root)
|
||||
?? "The Agent answered with an MTConnectError document carrying no error detail.";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -379,7 +507,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return boundary;
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||
/// <summary>
|
||||
/// A read path's request URI, scoped to the configured device. Reads <see cref="_agentRoot"/>,
|
||||
/// so it may only be called after that field is assigned — which the constructor does first.
|
||||
/// </summary>
|
||||
private Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||
{
|
||||
var deviceName = options.DeviceName?.Trim();
|
||||
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
|
||||
|
||||
return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="MTConnectDriverOptions.AgentUri"/> and reduces it to the base every
|
||||
/// request path is appended to: its left part up to and including the path (so a proxied
|
||||
/// Agent keeps its prefix), with any trailing slash removed so callers can append
|
||||
/// <c>/segment</c> unconditionally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extracted rather than duplicated because the write path resolves its device at call time,
|
||||
/// not at construction: two copies of this rule would drift, and the drift would surface as
|
||||
/// writes landing on a URL the reads never used — against an Agent that answers 200 either
|
||||
/// way.
|
||||
/// </remarks>
|
||||
private static string ResolveAgentRoot(MTConnectDriverOptions options)
|
||||
{
|
||||
var agentUri = options.AgentUri?.Trim();
|
||||
if (string.IsNullOrEmpty(agentUri))
|
||||
@@ -395,11 +547,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||
}
|
||||
|
||||
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
var deviceName = options.DeviceName?.Trim();
|
||||
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
|
||||
|
||||
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
|
||||
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -44,12 +44,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Scope.</b> This type implements <see cref="IDriver"/>, <see cref="IReadable"/>,
|
||||
/// <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
|
||||
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
|
||||
/// model). There is deliberately no <c>IWritable</c>: MTConnect is a read-only protocol,
|
||||
/// which is also why every discovered leaf is
|
||||
/// <see cref="SecurityClassification.ViewOnly"/>.
|
||||
/// model).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
|
||||
/// off by default.</b> A write goes to the Agent's <c>AllowPut</c> surface
|
||||
/// (<c>POST {agent}/{device}?{dataItemId}={value}</c>) and sets what the Agent <i>reports</i>
|
||||
/// for that DataItem — visible to every consumer of that Agent, and overwritten by the next
|
||||
/// adapter/SHDR update. Nothing reaches the CNC. It is therefore gated twice, and off at both
|
||||
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and this driver's
|
||||
/// <see cref="MTConnectWriteOptions.Enabled"/>. Either gate alone leaves the shipped read-only
|
||||
/// behaviour byte-for-byte unchanged. CONDITION data items are excluded — they carry alarm
|
||||
/// semantics and a different <c>level|text</c> wire format. MTConnect <b>Interfaces</b>, the
|
||||
/// protocol's own request/response handshake, remains deferred and is a different mechanism
|
||||
/// entirely (see <c>docs/plans/2026-08-05-mtconnect-writeback-design.md</c> §2.1).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The read path never writes the shared observation index</b> — see
|
||||
@@ -60,15 +71,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
|
||||
public sealed class MTConnectDriver
|
||||
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
|
||||
{
|
||||
/// <summary>
|
||||
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
|
||||
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
|
||||
/// machine has no value — the two failures need different operator responses.
|
||||
/// machine has no value — the two failures need different operator responses. On the write
|
||||
/// path it also covers an Agent that answered and <i>refused</i> — see <see cref="WriteAsync"/>.
|
||||
/// </summary>
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
// ---- Write status codes ----
|
||||
//
|
||||
// NAMED constants, never inline literals: StatusCodeParityTests checks each against the pinned SDK
|
||||
// by reflection and can only see a named const (Gitea #497/#519). The names must match
|
||||
// Opc.Ua.StatusCodes members exactly. Spelled WITHOUT the "Status" prefix MqttDriver uses, to match
|
||||
// the two this file already declares (the parity guard strips either spelling).
|
||||
|
||||
/// <summary>
|
||||
/// The reference names no DataItem this driver serves, or names one whose owning device cannot
|
||||
/// be resolved — there is no Agent URL a write could be addressed to.
|
||||
/// </summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>
|
||||
/// Writing is switched off (<c>write.enabled</c>), or the DataItem is CONDITION-category. The
|
||||
/// honest answer to "can I write this?", and deliberately not a communication failure: nothing
|
||||
/// was attempted and no Agent was involved.
|
||||
/// </summary>
|
||||
private const uint BadNotWritable = 0x803B0000u;
|
||||
|
||||
/// <summary>
|
||||
/// The supplied value has no invariant string form a PUT could carry — <c>null</c>, or a
|
||||
/// non-scalar (see <see cref="TryFormatValue"/>).
|
||||
/// </summary>
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// There is no Agent client at all: the driver has not been initialized, its initialize
|
||||
/// faulted, or it has been shut down. Deliberately not one of the index's codes — none of
|
||||
@@ -694,6 +733,220 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable: observation injection through the Agent's AllowPut surface ----
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Agent's reported observation for each requested reference, through its
|
||||
/// <c>AllowPut</c> surface. Returns one status per request, in request order.
|
||||
/// </summary>
|
||||
/// <param name="writes">The requested writes.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the batch.</param>
|
||||
/// <returns>One <see cref="WriteResult"/> per request, in request order.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This writes the Agent, not the machine</b> — see the class remarks. Good here means
|
||||
/// "the Agent took the observation", which is a stronger statement than MQTT's optimistic
|
||||
/// "published" (the Agent answers) and a weaker one than a device acknowledgement (no
|
||||
/// MTConnect Agent speaks for the machine). The next adapter update for that DataItem
|
||||
/// overwrites what was written, and the ordinary <c>/sample</c> ingest path is what
|
||||
/// corrects the node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Never throws, and one bad slot never fails the batch</b> — matching
|
||||
/// <see cref="ReadAsync"/>. Failing the whole call would let one stale reference reject
|
||||
/// every other write in the same OPC UA request. Even cancellation returns a full-length
|
||||
/// batch rather than propagating, so the node-manager can tell which slots were attempted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Refusal order is fixed, and each step answers a different operator question:</b>
|
||||
/// writing switched off ⇒ <c>BadNotWritable</c>; no live client ⇒ <c>BadNotConnected</c>
|
||||
/// (this driver is not running — <see cref="ReadAsync"/>'s convention); the reference
|
||||
/// resolves to no DataItem, or to one whose owning device cannot be named ⇒
|
||||
/// <c>BadNodeIdUnknown</c>; CONDITION category ⇒ <c>BadNotWritable</c>; no invariant string
|
||||
/// form ⇒ <c>BadTypeMismatch</c>; and only then the Agent, whose refusal <b>or</b> whose
|
||||
/// unreachability ⇒ <c>BadCommunicationError</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The Agent's refusal text is logged, never stored.</b> It goes to a Warning line and
|
||||
/// nowhere near <see cref="DriverHealth.LastError"/>, which is type-only by repo-wide
|
||||
/// discipline — and a refused write is not evidence the read path degraded, so health is
|
||||
/// left exactly as it is (unlike <see cref="DegradeAfterReadFailure"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="writes"/> is <c>null</c> — a caller bug.</exception>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
|
||||
if (writes.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
|
||||
// against ONE options document and ONE tag table, never half against a table a concurrent
|
||||
// re-initialize just replaced.
|
||||
var options = _options;
|
||||
var client = Volatile.Read(ref _session)?.Client;
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var request = writes[i];
|
||||
|
||||
if (!options.Write.Enabled)
|
||||
{
|
||||
results[i] = new WriteResult(BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
results[i] = new WriteResult(BadNotConnected);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!binding.TryResolveWriteTarget(request.FullReference, out var dataItemId, out var target)
|
||||
|| target.DeviceName is null)
|
||||
{
|
||||
results[i] = new WriteResult(BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.IsCondition)
|
||||
{
|
||||
results[i] = new WriteResult(BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryFormatValue(request.Value, out var text))
|
||||
{
|
||||
results[i] = new WriteResult(BadTypeMismatch);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await client
|
||||
.WriteObservationAsync(target.DeviceName, dataItemId, text, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (outcome.Accepted)
|
||||
{
|
||||
results[i] = new WriteResult(0u);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.",
|
||||
_driverInstanceId, options.AgentUri, text, dataItemId, target.DeviceName, outcome.AgentError);
|
||||
|
||||
results[i] = new WriteResult(BadCommunicationError);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
|
||||
for (var j = i; j < writes.Count; j++)
|
||||
{
|
||||
results[j] = new WriteResult(BadCommunicationError);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A transport failure, a blown per-call deadline, or a disposed client. Logged with the
|
||||
// exception (its TYPE is the diagnosis) and reported per slot; health is untouched.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not write DataItem '{DataItemId}' on device '{DeviceName}' to {AgentUri}.",
|
||||
_driverInstanceId, dataItemId, target.DeviceName, options.AgentUri);
|
||||
|
||||
results[i] = new WriteResult(BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a value to the invariant string a PUT query carries, or reports that it has no such
|
||||
/// form.
|
||||
/// </summary>
|
||||
/// <param name="value">The value the OPC UA client supplied.</param>
|
||||
/// <param name="text">The rendered value; empty when this returns <see langword="false"/>.</param>
|
||||
/// <returns><see langword="false"/> for a value that must be refused as a type mismatch.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="CultureInfo.InvariantCulture"/> is load-bearing.</b> A comma decimal
|
||||
/// separator produces a value the Agent accepts and every consumer reads back as a
|
||||
/// different number. Booleans render as <c>True</c>/<c>False</c> —
|
||||
/// <see cref="bool.ToString()"/> is culture-independent, so that IS the invariant form, and
|
||||
/// it is what an MTConnect consumer will see verbatim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Arrays are refused rather than rendered.</b>
|
||||
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
|
||||
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
|
||||
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
|
||||
/// happily, making the corruption invisible until someone reads the observation back.
|
||||
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
|
||||
/// rendering runs the value's own <see cref="object.ToString"/> /
|
||||
/// <see cref="IFormattable"/>, which is arbitrary code — and this runs OUTSIDE
|
||||
/// <see cref="WriteAsync"/>'s per-slot <c>try</c>, so anything that escapes here escapes
|
||||
/// the whole batch and breaks its never-throws contract. It costs its own slot a
|
||||
/// <c>BadTypeMismatch</c> and is logged.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool TryFormatValue(object? value, out string text)
|
||||
{
|
||||
text = string.Empty;
|
||||
|
||||
// A PUT query has no null to send, and the empty string is a real observation rather than an
|
||||
// absence — so "no value" is refused instead of being silently turned into one.
|
||||
if (value is null or Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? rendered;
|
||||
try
|
||||
{
|
||||
rendered = Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Catch-all, and deliberately so. Convert.ToString falls back to the value's OWN
|
||||
// ToString/IFormattable, which is arbitrary caller code that may throw ANYTHING — and this
|
||||
// call sits outside WriteAsync's per-slot try, so a narrower filter (FormatException /
|
||||
// InvalidCastException / OverflowException) let every other exception escape WriteAsync
|
||||
// uncaught and break the documented never-throws contract for the whole batch. One
|
||||
// unformattable value must cost its own slot, nothing more.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not render a write value of type {ValueType} to its invariant string form; the write is refused as a type mismatch.",
|
||||
_driverInstanceId,
|
||||
value.GetType());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rendered is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
text = rendered;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- ISubscribable: ONE shared /sample long poll behind every handle ----
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -940,7 +1193,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
|
||||
// Read once: a concurrent re-initialize must not scope half this walk to one device and half
|
||||
// to another.
|
||||
var deviceScope = _options.DeviceName?.Trim();
|
||||
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
|
||||
// device and half to another, nor stamp half its leaves writable and half not.
|
||||
var options = _options;
|
||||
var deviceScope = options.DeviceName?.Trim();
|
||||
var writeEnabled = options.Write.Enabled;
|
||||
|
||||
var devices = 0;
|
||||
var variables = 0;
|
||||
@@ -966,7 +1223,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
|
||||
devices++;
|
||||
variables += StreamContainer(device, builder.Folder(name, name));
|
||||
variables += StreamContainer(device, builder.Folder(name, name), writeEnabled);
|
||||
}
|
||||
|
||||
// A configured scope that matches NO device is an authoring error — almost always a typo or
|
||||
@@ -1003,7 +1260,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
/// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
|
||||
/// document nested beyond its own limit.
|
||||
/// </remarks>
|
||||
private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
|
||||
/// <param name="container">The device or component whose data items are being streamed.</param>
|
||||
/// <param name="scope">The builder folder they belong under.</param>
|
||||
/// <param name="writeEnabled">
|
||||
/// <see cref="MTConnectWriteOptions.Enabled"/>, read once by the caller so one walk cannot
|
||||
/// stamp half its leaves under an old options document and half under a new one.
|
||||
/// </param>
|
||||
private static int StreamContainer(
|
||||
IMTConnectComponentContainer container, IAddressSpaceBuilder scope, bool writeEnabled)
|
||||
{
|
||||
var streamed = 0;
|
||||
|
||||
@@ -1037,9 +1301,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
IsArray: inferred.IsArray,
|
||||
ArrayDim: inferred.ArrayDim,
|
||||
|
||||
// MTConnect is read-only and this driver implements no IWritable, so the tier
|
||||
// that is read-only from OPC UA is the only honest one.
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
// Operate exactly for the leaves WriteAsync would accept — the "a node cannot
|
||||
// advertise writability the driver would refuse" principle. With write.enabled
|
||||
// off (the default) every leaf stays ViewOnly, which is what this driver shipped.
|
||||
//
|
||||
// ⚠️ This does NOT make the committed tag writable over OPC UA, and must not be
|
||||
// read as doing so. Nothing persists SecurityClass: RawBrowseCommitMapper.MapLeaf
|
||||
// hardcodes TagAccessLevel.Read, Tag has no SecurityClass column, and the
|
||||
// CurrentWrite bit is composed from Tag.AccessLevel alone
|
||||
// (OtOpcUaNodeManager.ComposeAccessLevel). The stamp reaches the browse session's
|
||||
// attribute side-channel and stops there, so an operator still has to author
|
||||
// ReadWrite on the tag. It is kept honest anyway — a browse that advertised
|
||||
// ViewOnly for a leaf this driver now writes would be a lie an operator reads.
|
||||
SecurityClass: CanWrite(writeEnabled, dataItem)
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
|
||||
// Historization is authored per tag, never inferred from a device model.
|
||||
IsHistorized: false,
|
||||
@@ -1067,12 +1343,26 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
continue;
|
||||
}
|
||||
|
||||
streamed += StreamContainer(component, scope.Folder(name, name));
|
||||
streamed += StreamContainer(component, scope.Folder(name, name), writeEnabled);
|
||||
}
|
||||
|
||||
return streamed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="WriteAsync"/> would accept a write to <paramref name="dataItem"/> — the
|
||||
/// browse-time answer, asked of the same two facts the runtime asks of.
|
||||
/// </summary>
|
||||
/// <param name="writeEnabled">Whether <c>write.enabled</c> is on.</param>
|
||||
/// <param name="dataItem">The declared data item.</param>
|
||||
/// <remarks>
|
||||
/// The device-name leg of the runtime refusal is not asked here and does not need to be: a
|
||||
/// leaf reached by this walk is <i>inside</i> a device the model declares, so its owning device
|
||||
/// is known by construction.
|
||||
/// </remarks>
|
||||
private static bool CanWrite(bool writeEnabled, MTConnectDataItem dataItem) =>
|
||||
writeEnabled && !IsCondition(dataItem.Category);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
|
||||
/// </summary>
|
||||
@@ -2276,6 +2566,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000,
|
||||
BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0,
|
||||
},
|
||||
|
||||
// Absent block == disabled: write-back is opt-in, and the agent's own AllowPut has to be
|
||||
// on as well, so neither layer can arm the other by omission.
|
||||
Write = new MTConnectWriteOptions
|
||||
{
|
||||
Enabled = dto.Write?.Enabled ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2926,10 +3223,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
|
||||
if (options.RawTags.Count == 0)
|
||||
{
|
||||
MTConnectTagDefinition[] only = [.. definitions.Values];
|
||||
|
||||
return new TagBinding(
|
||||
FrozenDictionary<string, string>.Empty,
|
||||
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
|
||||
[.. definitions.Values]);
|
||||
only,
|
||||
BuildWriteTargets(only, probeModel, options.DeviceName));
|
||||
}
|
||||
|
||||
var declared = IndexProbeDataItems(probeModel);
|
||||
@@ -2975,13 +3275,106 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
}
|
||||
|
||||
MTConnectTagDefinition[] indexTags = [.. definitions.Values];
|
||||
|
||||
return new TagBinding(
|
||||
idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
rawPathsById.ToFrozenDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (IReadOnlyList<string>)pair.Value,
|
||||
StringComparer.Ordinal),
|
||||
[.. definitions.Values]);
|
||||
indexTags,
|
||||
BuildWriteTargets(indexTags, probeModel, options.DeviceName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the write-addressing table: for every DataItem this driver serves, the device the
|
||||
/// Agent's <c>AllowPut</c> URL must name, and whether the item is CONDITION-category.
|
||||
/// </summary>
|
||||
/// <param name="definitions">Every DataItem the binding serves (the union of both authoring surfaces).</param>
|
||||
/// <param name="probeModel">The Agent's device model when one has been fetched; else null.</param>
|
||||
/// <param name="configuredDeviceName">
|
||||
/// <see cref="MTConnectDriverOptions.DeviceName"/> — the fallback for a DataItem the
|
||||
/// <c>/probe</c> model does not declare.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The device name is derived, not authored.</b> The Agent's PUT surface is addressed
|
||||
/// per device even on a single-device Agent, and the probe model already says which device
|
||||
/// owns which DataItem — so a multi-device Agent is addressed correctly with no extra
|
||||
/// config. Config is the fallback, for the dataItemId-keyed authoring surface (the driver
|
||||
/// CLI) and for a DataItem the Agent has since stopped declaring.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A null <see cref="WriteTarget.DeviceName"/> is a refusal, not a default.</b> Neither
|
||||
/// the model nor config names a device, so there is no URL to POST to; guessing one yields
|
||||
/// a 404 an operator reads as "the Agent is broken". <see cref="WriteAsync"/> reports it as
|
||||
/// <c>BadNodeIdUnknown</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The category comes from the probe first, the authored blob second.</b> The Agent is
|
||||
/// the authority on what a DataItem is; a blob's <c>mtCategory</c> is optional metadata
|
||||
/// that may be absent (browse-commit writes only the address) or stale.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static FrozenDictionary<string, WriteTarget> BuildWriteTargets(
|
||||
IReadOnlyList<MTConnectTagDefinition> definitions,
|
||||
MTConnectProbeModel? probeModel,
|
||||
string? configuredDeviceName)
|
||||
{
|
||||
if (definitions.Count == 0)
|
||||
{
|
||||
return FrozenDictionary<string, WriteTarget>.Empty;
|
||||
}
|
||||
|
||||
var owners = IndexProbeOwners(probeModel);
|
||||
var fallback = string.IsNullOrWhiteSpace(configuredDeviceName) ? null : configuredDeviceName.Trim();
|
||||
var targets = new Dictionary<string, WriteTarget>(definitions.Count, StringComparer.Ordinal);
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
if (definition is null || string.IsNullOrWhiteSpace(definition.FullName)) continue;
|
||||
|
||||
var owner = owners.TryGetValue(definition.FullName, out var declared) ? declared : null;
|
||||
|
||||
targets[definition.FullName] = new WriteTarget(
|
||||
owner?.DeviceName ?? fallback,
|
||||
IsCondition(owner?.Category ?? definition.MtCategory));
|
||||
}
|
||||
|
||||
return targets.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens a device model into a <c>dataItemId → owning device</c> lookup; null ⇒ empty.
|
||||
/// Separate from <see cref="IndexProbeDataItems"/> because this one must remember which device
|
||||
/// the walk was inside, which a flat DataItem lookup has thrown away.
|
||||
/// </summary>
|
||||
/// <param name="probeModel">The Agent's device model, or null.</param>
|
||||
private static FrozenDictionary<string, DataItemOwner> IndexProbeOwners(MTConnectProbeModel? probeModel)
|
||||
{
|
||||
if (probeModel is null) return FrozenDictionary<string, DataItemOwner>.Empty;
|
||||
|
||||
var map = new Dictionary<string, DataItemOwner>(StringComparer.Ordinal);
|
||||
foreach (var device in probeModel.Devices)
|
||||
{
|
||||
if (device is null) continue;
|
||||
|
||||
// The same name/id fallback the browse tree uses: an Agent addresses a device by its
|
||||
// name, and by its id when it declares no name.
|
||||
var deviceName = FolderName(device.Name, device.Id);
|
||||
var owner = new DataItemOwner(
|
||||
string.IsNullOrWhiteSpace(deviceName) ? null : deviceName, Category: null);
|
||||
|
||||
foreach (var dataItem in device.AllDataItems())
|
||||
{
|
||||
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue;
|
||||
|
||||
map[dataItem.Id] = owner with { Category = dataItem.Category };
|
||||
}
|
||||
}
|
||||
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3167,11 +3560,45 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
/// <see cref="MTConnectDriverOptions.RawTags"/>. This is how a raw tag's declared (or
|
||||
/// probe-inferred) <c>DriverDataType</c> reaches the coercion.
|
||||
/// </param>
|
||||
/// <param name="WriteTargetsByDataItemId">
|
||||
/// How each DataItem is addressed on the Agent's <c>AllowPut</c> surface, and whether it is
|
||||
/// CONDITION-category — see <see cref="BuildWriteTargets"/>. Keyed by DataItem id, so it
|
||||
/// doubles as the write path's "is this a reference I serve at all?" test: a miss is
|
||||
/// <c>BadNodeIdUnknown</c>, exactly as an index miss is on the read path.
|
||||
/// </param>
|
||||
private sealed record TagBinding(
|
||||
FrozenDictionary<string, string> DataItemIdByRawPath,
|
||||
FrozenDictionary<string, IReadOnlyList<string>> RawPathsByDataItemId,
|
||||
IReadOnlyList<MTConnectTagDefinition> IndexTags)
|
||||
IReadOnlyList<MTConnectTagDefinition> IndexTags,
|
||||
FrozenDictionary<string, WriteTarget> WriteTargetsByDataItemId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a write reference to the DataItem id and the Agent address it writes through.
|
||||
/// </summary>
|
||||
/// <param name="reference">The caller's reference (a RawPath under v3, or a DataItem id).</param>
|
||||
/// <param name="dataItemId">The resolved DataItem id; empty when this returns false.</param>
|
||||
/// <param name="target">Where and whether it can be written; <c>default</c> when false.</param>
|
||||
/// <returns><see langword="false"/> when no DataItem this driver serves answers to that reference.</returns>
|
||||
/// <remarks>
|
||||
/// Stricter than <see cref="ToDataItemId"/>, deliberately. A read may fall through to an
|
||||
/// index miss and report itself; a write that fell through would POST an arbitrary
|
||||
/// operator string at the Agent as if it were a DataItem id — the Agent's answer to which
|
||||
/// is a refusal that reads like a comms fault. Unknown is answered here, before the wire.
|
||||
/// </remarks>
|
||||
public bool TryResolveWriteTarget(string? reference, out string dataItemId, out WriteTarget target)
|
||||
{
|
||||
dataItemId = ToDataItemId(reference);
|
||||
|
||||
if (dataItemId.Length != 0 && WriteTargetsByDataItemId.TryGetValue(dataItemId, out target))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
target = default;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates a subscriber/read reference to the DataItem id the index is keyed by.
|
||||
/// </summary>
|
||||
@@ -3211,6 +3638,24 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything <see cref="WriteAsync"/> needs to decide one slot before it touches the network.
|
||||
/// </summary>
|
||||
/// <param name="DeviceName">
|
||||
/// The device path segment of the Agent PUT URL, or <c>null</c> when neither the probe model
|
||||
/// nor config names one — which is a refusal, not a default (see <see cref="BuildWriteTargets"/>).
|
||||
/// </param>
|
||||
/// <param name="IsCondition">
|
||||
/// Whether the DataItem is CONDITION-category. Excluded from v1 writes: a condition PUT carries
|
||||
/// alarm semantics and a different <c>level|text</c> wire format.
|
||||
/// </param>
|
||||
private readonly record struct WriteTarget(string? DeviceName, bool IsCondition);
|
||||
|
||||
/// <summary>The device a DataItem was declared under, and the category it was declared with.</summary>
|
||||
/// <param name="DeviceName">The owning device's name (or id); null when it declares neither.</param>
|
||||
/// <param name="Category">The DataItem's <c>category</c> attribute as the Agent spelled it.</param>
|
||||
private sealed record DataItemOwner(string? DeviceName, string? Category);
|
||||
|
||||
/// <summary>
|
||||
/// One live subscription: its handle and the references it wants. Both are effectively
|
||||
/// immutable once registered, so the pump reads <see cref="References"/> without a lock —
|
||||
@@ -3272,6 +3717,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
public MTConnectProbeDto? Probe { get; init; }
|
||||
public MTConnectReconnectDto? Reconnect { get; init; }
|
||||
public MTConnectWriteDto? Write { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3347,4 +3793,10 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
public int? MaxBackoffMs { get; init; }
|
||||
public double? BackoffMultiplier { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Write-back knobs — the driver-side half of the two-layer opt-in on observation injection.</summary>
|
||||
private sealed class MTConnectWriteDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
|
||||
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
|
||||
/// capability (<see cref="IReadable"/>, <see cref="IWritable"/>, <see cref="ISubscribable"/>,
|
||||
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
|
||||
/// <see cref="IRediscoverable"/>) by pattern-matching the object
|
||||
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
|
||||
/// narrowed return is how a capability ends up implemented but never dispatched. There is
|
||||
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
|
||||
/// narrowed return is how a capability ends up implemented but never dispatched.
|
||||
/// <see cref="IWritable"/> is the standing example: it was added to
|
||||
/// <see cref="MTConnectDriver"/> with no change whatsoever to this file, and would have been
|
||||
/// silently undispatched had the return type ever been narrowed to <see cref="IDriver"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Registering the factory does not enable writing.</b> An MTConnect write is observation
|
||||
/// injection through the Agent's <c>AllowPut</c> surface and is off at two independent gates
|
||||
/// — the Agent's <c>agent.cfg</c> and the driver's own <c>write.enabled</c> (default
|
||||
/// <see langword="false"/>). See <see cref="MTConnectDriver"/>'s remarks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MTConnectDriverFactoryExtensions
|
||||
|
||||
@@ -127,6 +127,40 @@ internal static class MTConnectXml
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = ExtractErrorText(root);
|
||||
|
||||
return errors is null ? message : $"{message} The Agent reported: {errors}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's own words from an <c>MTConnectError</c> document: every <c><Error></c>
|
||||
/// beneath <paramref name="root"/>, rendered <c>errorCode: text</c> where a code is present
|
||||
/// and joined with <c>"; "</c>. <c>null</c> when the document carries no readable error
|
||||
/// detail at all (no <c><Error></c> elements, or only blank ones).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Shared because both callers must read the same document the same way.</b> An
|
||||
/// Agent's objection reaches this driver through two unrelated doors — a read path that
|
||||
/// got an error document where it expected a model
|
||||
/// (<see cref="DescribeUnexpectedRoot"/>), and the write path deciding whether a 200
|
||||
/// actually accepted the observation. Two copies of this walk would drift on the same
|
||||
/// two silent rules the type-level remarks describe, and on the write side a missed
|
||||
/// <c><Error></c> does not merely degrade a message: it reads as a successful
|
||||
/// write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>null</c> means "no readable detail", NOT "not an error document".</b> Only the
|
||||
/// caller knows which document it is holding — check the root name first. A caller that
|
||||
/// treats a <c>null</c> here as "no error occurred" turns a hollow
|
||||
/// <c><MTConnectError/></c> into a success.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="root">The <c>MTConnectError</c> document's root element.</param>
|
||||
public static string? ExtractErrorText(XElement root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
@@ -139,8 +173,6 @@ internal static class MTConnectXml
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
return errors.Count == 0 ? null : string.Join("; ", errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
/// <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.
|
||||
/// read verbatim and its <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/> — so the bind names cannot drift from the markers the
|
||||
/// dialect's own SQL carries; 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
|
||||
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <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.
|
||||
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
{
|
||||
var rows = await QueryAsync(
|
||||
_dialect.ListTablesSql,
|
||||
command => Bind(command, "@schema", schema),
|
||||
command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
|
||||
static reader => (
|
||||
Name: ReadString(reader, TableNameColumn),
|
||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
_dialect.ListColumnsSql,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, _dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
|
||||
var results = new List<T>();
|
||||
await using var command = _connection.CreateCommand();
|
||||
// Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
|
||||
// column query is exactly where positional binding swaps the schema and the table silently.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
bind(command);
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
/// <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.
|
||||
/// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how
|
||||
/// a further provider gets added without touching this class.
|
||||
/// </param>
|
||||
/// <param name="environmentReader">
|
||||
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
|
||||
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
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.
|
||||
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
|
||||
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
|
||||
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
|
||||
/// unavailable rather than crashing on a null dialect.
|
||||
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
|
||||
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
|
||||
/// against a database the AdminUI refuses to show them.</para>
|
||||
/// </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;
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
@@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
var literal = ReadPastedConnectionString(configJson);
|
||||
var config = Deserialize(configJson, literal);
|
||||
|
||||
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
|
||||
// Provider first: "this build cannot browse MySql" 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.");
|
||||
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
|
||||
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
|
||||
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
/// </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>
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
|
||||
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
/// <summary>
|
||||
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
|
||||
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
|
||||
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
|
||||
/// </summary>
|
||||
public enum SqlProvider
|
||||
{
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
|
||||
SqlServer,
|
||||
|
||||
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
|
||||
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
MySql,
|
||||
|
||||
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
Odbc,
|
||||
|
||||
/// <summary>Oracle — reserved; not constructed in v1.</summary>
|
||||
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
|
||||
Oracle,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// </summary>
|
||||
public interface ISqlDialect
|
||||
{
|
||||
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
|
||||
/// <summary>
|
||||
/// Which backend this dialect speaks. One implementation ships per constructed
|
||||
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
|
||||
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
|
||||
/// </summary>
|
||||
SqlProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -50,6 +54,58 @@ public interface ISqlDialect
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||
string QuoteIdentifier(string ident);
|
||||
|
||||
/// <summary>
|
||||
/// The token that stands for a bound parameter <b>in the command text</b> — <c>"@k0"</c> for
|
||||
/// SqlClient/Npgsql, <c>":p_k0"</c> for ODP.NET — given the caller's bare base name (<c>"k0"</c>,
|
||||
/// <c>"w"</c>, <c>"schema"</c>).
|
||||
/// <para><b>Why the marker and the bind name are two members.</b> They are the same string on every
|
||||
/// provider but Oracle, where the SQL text carries <c>:p_schema</c> while
|
||||
/// <see cref="DbParameter.ParameterName"/> is the bare <c>schema</c>. Modelling one and deriving the
|
||||
/// other means a call site guessing at a convention it does not own — the exact defect this seam
|
||||
/// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
|
||||
/// bind-by-position, as a <em>silently mis-bound value</em>) rather than a compile error.</para>
|
||||
/// <para>The base name is caller-owned and never operator input: the poll planner mints
|
||||
/// <c>k0…kN</c>/<c>w</c>, the catalog path uses <c>schema</c>/<c>table</c>. Authored <em>values</em>
|
||||
/// ride the parameter; authored <em>identifiers</em> go through <see cref="QuoteIdentifier"/>. Nothing
|
||||
/// else reaches a command text.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The marker to embed in the SQL text.</returns>
|
||||
string ParameterMarker(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// The value to assign to <see cref="DbParameter.ParameterName"/> for the same
|
||||
/// <paramref name="baseName"/> — <c>"@k0"</c> for SqlClient-style providers (which expect the marker
|
||||
/// verbatim), the bare <c>"k0"</c> for ODP.NET-style ones (whose documented convention is a
|
||||
/// prefix-less bind name).
|
||||
/// <para>Always paired with <see cref="ParameterMarker"/> over the <em>same</em> base name: a plan
|
||||
/// carries both lists and every binding site <b>copies</b> them index-for-index rather than
|
||||
/// re-deriving either from the other.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The parameter name to bind under.</returns>
|
||||
string ParameterBindName(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// Applies provider-specific setup to a freshly created <see cref="DbCommand"/>, <b>before</b> its
|
||||
/// parameters are added or it is executed. A no-op for providers that need none.
|
||||
/// <para><b>Oracle uses this to set <c>OracleCommand.BindByName = true</c></b>, and that call is the
|
||||
/// sharpest correctness edge on this seam: ODP.NET binds <em>by position</em> by default, so a
|
||||
/// multi-parameter statement (the planner's <c>IN (:p_k0, :p_k1, …)</c> list, the catalog path's
|
||||
/// <c>schema</c> + <c>table</c> pair) would bind values to the wrong markers whenever the parameter
|
||||
/// order and the marker order differ — publishing one tag's value onto another tag's node with no
|
||||
/// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
|
||||
/// browse path — therefore calls this immediately after <c>CreateCommand()</c> and <b>before</b> the
|
||||
/// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
|
||||
/// ones are indifferent: <c>SqlDriverProbe</c> calls it anyway for uniformity,
|
||||
/// <c>SqlDriver.PingAsync</c> does not.)</para>
|
||||
/// <para>Implementations must tolerate <em>any</em> <see cref="DbCommand"/> they are handed: the
|
||||
/// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
|
||||
/// provider type must do so defensively rather than throwing.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure.</param>
|
||||
void ConfigureCommand(DbCommand command);
|
||||
|
||||
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||
string LivenessSql { get; }
|
||||
|
||||
@@ -105,10 +161,19 @@ public interface ISqlDialect
|
||||
/// </remarks>
|
||||
string DefaultSchemaSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||
/// <summary>
|
||||
/// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
|
||||
/// parameter under the base name <c>schema</c>, spelled with this dialect's own
|
||||
/// <see cref="ParameterMarker"/> (<c>@schema</c> on SqlClient, <c>:p_schema</c> on ODP.NET); callers bind
|
||||
/// it under <see cref="ParameterBindName"/> of the same base name.
|
||||
/// </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>
|
||||
/// <summary>
|
||||
/// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
|
||||
/// parameters under the base names <c>schema</c> and <c>table</c>, spelled and bound exactly as
|
||||
/// <see cref="ListTablesSql"/> describes.
|
||||
/// </summary>
|
||||
string ListColumnsSql { get; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
|
||||
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
|
||||
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>colon-less</b> bind names, the
|
||||
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
|
||||
/// <c>FETCH FIRST</c> row limiting.
|
||||
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> arrived
|
||||
/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a
|
||||
/// long-but-legal-here identifier would be rejected by the server rather than by
|
||||
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
|
||||
/// <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 OracleDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// Oracle 12.2+'s identifier ceiling. 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.Oracle;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => OracleClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
|
||||
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
|
||||
/// <see cref="ParameterMarker"/>.
|
||||
/// </summary>
|
||||
private const string BindPrefix = "p_";
|
||||
|
||||
/// <summary>
|
||||
/// Oracle markers are <c>:</c>-prefixed <b>and namespaced</b> — <c>ParameterMarker("k0")</c> is
|
||||
/// <c>":p_k0"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The <c>p_</c> is load-bearing, and was found live.</b> Oracle rejects a bind variable
|
||||
/// whose name is a reserved word with <c>ORA-01745: invalid host/bind variable name</c> — and the
|
||||
/// catalog readers' own base names include <c>table</c> (<c>SqlCatalogLoader</c> /
|
||||
/// <c>SqlBrowseSession</c> bind <c>schema</c> + <c>table</c>). So an un-prefixed
|
||||
/// <see cref="ListColumnsSql"/> could not execute at all: every Oracle driver instance failed its
|
||||
/// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's
|
||||
/// columns threw. Caught by <c>OracleReadTests</c> against a real database; invisible to any offline
|
||||
/// test, because the SQL is only ever parsed by a server.</para>
|
||||
/// <para>Prefixing <em>everything</em> rather than maintaining a reserved-word list is deliberate:
|
||||
/// the list is long, version-dependent, and a future base name (<c>date</c>, <c>user</c>,
|
||||
/// <c>comment</c>, <c>level</c>…) would re-open the same hole silently. A prefixed name can never be
|
||||
/// a reserved word, and the marker and the bind name are derived from the same constant, so they
|
||||
/// cannot drift apart.</para>
|
||||
/// </remarks>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>:</c>-prefixed, namespaced marker.</returns>
|
||||
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// The same namespaced name <b>without the <c>:</c></b> — ODP.NET's documented convention for
|
||||
/// <see cref="DbParameter.ParameterName"/>, and deliberately <em>not</em>
|
||||
/// <see cref="ParameterMarker"/>. This is the divergence that makes the two members exist at all
|
||||
/// (see <see cref="ISqlDialect.ParameterBindName"/>).
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The colon-less bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => BindPrefix + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Sets <c>OracleCommand.BindByName = true</c>. <b>This is the sharpest correctness edge in the
|
||||
/// driver's provider support.</b>
|
||||
/// <para>ODP.NET binds <em>by position</em> by default: parameter 0 fills the first marker in the
|
||||
/// statement, parameter 1 the second, and so on, <em>regardless of the names</em>. Every statement this
|
||||
/// driver builds with more than one marker would then be a coin flip — the planner's
|
||||
/// <c>IN (:p_k0, :p_k1, …)</c> key list, and <see cref="ListColumnsSql"/>'s <c>schema</c> + <c>table</c>
|
||||
/// pair — and the failure mode is not an exception but a <em>successful query over the wrong rows</em>,
|
||||
/// publishing one tag's value onto another tag's node. Worse still,
|
||||
/// <see cref="ListTablesSql"/> names <c>:p_schema</c> <b>twice</b> (once per branch of its
|
||||
/// <c>UNION ALL</c>) and is bound once: under positional binding that statement cannot even execute,
|
||||
/// because the second occurrence has no parameter to take.</para>
|
||||
/// <para>Must be called while the parameter collection is still empty — which is exactly what every
|
||||
/// call site does (immediately after <c>CreateCommand()</c>, before the first bind).</para>
|
||||
/// <para><b>A foreign command passes through untouched rather than throwing.</b> The dialect can be
|
||||
/// handed a <see cref="DbCommand"/> from another provider — an instrumented or substituted factory in
|
||||
/// a shared test harness is the realistic case — and an <c>InvalidCastException</c> there would fail a
|
||||
/// poll for a reason that has nothing to do with the database. There is nothing to configure on a
|
||||
/// non-ODP.NET command anyway: every other provider this repo speaks binds by name already.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; only an <see cref="OracleCommand"/> is touched.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
if (command is OracleCommand oracle) oracle.BindByName = true;
|
||||
}
|
||||
|
||||
/// <summary>Oracle has no bare <c>SELECT</c>; every projection needs a table, and <c>DUAL</c> is the one-row one.</summary>
|
||||
public string LivenessSql => "SELECT 1 FROM DUAL";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Oracle 12c+ spells the row limit at the <em>end</em> of the statement (see
|
||||
/// <see cref="SingleRowLimitSuffix"/>). Anything here would land between <c>SELECT</c> and the column
|
||||
/// list, where Oracle has no limit syntax at all.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
|
||||
/// finished statement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately <b>not</b> the older <c>WHERE ROWNUM = 1</c>: <c>ROWNUM</c> is assigned <em>before</em>
|
||||
/// <c>ORDER BY</c> runs, so it would take an arbitrary row and then sort it — silently returning the
|
||||
/// wrong row for the <c>topByTimestamp</c> selector, whose entire purpose is "the newest one". Making
|
||||
/// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the
|
||||
/// statement shape, which this seam deliberately leaves with the planner.
|
||||
/// </remarks>
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <summary>
|
||||
/// Schemas visible to the connecting principal, from <c>ALL_USERS</c> (Oracle equates a schema with a
|
||||
/// user), aliased to the <c>TABLE_SCHEMA</c> column name the catalog readers project by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Broader than the SQL Server dialect's <c>DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES</c>,
|
||||
/// which lists only schemas that own an object: <c>ALL_USERS</c> includes empty ones. Harmless here —
|
||||
/// the list is used to resolve an authored schema name to the catalog's own spelling, and a schema
|
||||
/// holding no tables simply resolves and then matches no relation.
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
|
||||
|
||||
/// <summary>
|
||||
/// <c>sys_context('userenv','current_schema')</c> is the schema an unqualified name resolves in for
|
||||
/// this session — which is what the catalog gate asks, and it correctly follows an
|
||||
/// <c>ALTER SESSION SET CURRENT_SCHEMA</c> rather than reporting the login name.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
|
||||
|
||||
/// <summary>
|
||||
/// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
|
||||
/// <c>INFORMATION_SCHEMA.TABLES</c> carries both. The synthesised <c>TABLE_TYPE</c> literals are the
|
||||
/// <c>INFORMATION_SCHEMA</c> spellings the browser compares against to mark a view node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>UNION ALL</c> rather than <c>UNION</c>: the two sources are disjoint by construction (a name
|
||||
/// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort.
|
||||
/// <para>Note this statement names <c>:p_schema</c> twice and is bound <b>once</b> — see
|
||||
/// <see cref="ConfigureCommand"/>.</para>
|
||||
/// </remarks>
|
||||
public string ListTablesSql =>
|
||||
"SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " +
|
||||
"UNION ALL " +
|
||||
"SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " +
|
||||
"ORDER BY TABLE_NAME";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one relation, from <c>ALL_TAB_COLUMNS</c> — which covers views as well as tables, so it
|
||||
/// serves both halves of <see cref="ListTablesSql"/>. <c>column_id</c> is Oracle's
|
||||
/// <c>ORDINAL_POSITION</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ALL_TAB_COLUMNS.NULLABLE</c> is <c>Y</c>/<c>N</c> where <c>INFORMATION_SCHEMA.IS_NULLABLE</c> is
|
||||
/// <c>YES</c>/<c>NO</c>, so it is translated rather than merely aliased: a column that carries a
|
||||
/// shared name across dialects has to carry shared <em>values</em> too, or the first consumer to read
|
||||
/// it gets a different answer per provider.
|
||||
/// </remarks>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " +
|
||||
"CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " +
|
||||
"FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the Oracle escape rule, and the
|
||||
/// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the
|
||||
/// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Case matters once an identifier is quoted.</b> Oracle upper-cases unquoted identifiers but
|
||||
/// takes a quoted one literally, so <c>"speed"</c> and <c>"SPEED"</c> are different columns. This is
|
||||
/// safe here only because the catalog gate has already substituted the catalog's own spelling
|
||||
/// (<c>ALL_TAB_COLUMNS.column_name</c>, i.e. what the server actually stored) before anything reaches
|
||||
/// this method — an operator who authored <c>speed</c> against an Oracle <c>SPEED</c> column resolves
|
||||
/// to <c>SPEED</c> at Initialize, and it is <c>SPEED</c> that gets quoted.</para>
|
||||
/// <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="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
||||
/// BOM. Those last cannot escape the quotes, 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> — see
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> on the catalog gate. The rules below still run, and 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 double-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid Oracle 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(
|
||||
$"An Oracle 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 the quotes 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>ALL_TAB_COLUMNS.DATA_TYPE</c> family name onto a <see cref="DriverDataType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b><c>NUMBER</c> maps to <see cref="DriverDataType.Float64"/>, and that is a deliberate
|
||||
/// under-specification.</b> Oracle has one numeric type: an integer column, a currency column and a
|
||||
/// scientific measurement are all <c>NUMBER</c>, differing only in the precision/scale this method
|
||||
/// never sees — its whole input is the family name. Choosing an integral driver type would truncate
|
||||
/// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a
|
||||
/// <c>NUMBER(38)</c> beyond 2^53 loses low-order digits in a <c>double</c>, and an integer-valued
|
||||
/// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
|
||||
/// an explicit <c>"type"</c> on the tag (<c>Int64</c> for a true integer key, <c>String</c> to keep an
|
||||
/// exact high-precision decimal), which overrides this inference.</para>
|
||||
/// <para><b><c>TIMESTAMP</c> is matched by prefix</b>, unlike every other entry. Oracle reports the
|
||||
/// fractional-seconds precision and the time-zone qualifier <em>inside</em> the family name —
|
||||
/// <c>TIMESTAMP(6)</c>, <c>TIMESTAMP(6) WITH TIME ZONE</c>, <c>TIMESTAMP(6) WITH LOCAL TIME ZONE</c> —
|
||||
/// where <c>INFORMATION_SCHEMA</c> would report a bare <c>timestamp</c>. An exact-match table would
|
||||
/// send all of them to the String fallback.</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>RAW</c>,
|
||||
/// <c>LONG RAW</c>, <c>BLOB</c>, <c>BFILE</c>, <c>ROWID</c>, <c>XMLTYPE</c>, an object type) rather
|
||||
/// than crash on it. Null/blank input takes the same fallback. <c>RAW</c>/<c>BLOB</c> land here for
|
||||
/// the same reason SQL Server's <c>varbinary</c> does: there is no binary driver type.</para>
|
||||
/// <para><b>Deliberate non-mapping:</b> the <c>INTERVAL</c> types are durations, not points in time,
|
||||
/// and take the String fallback rather than being mistaken for a
|
||||
/// <see cref="DriverDataType.DateTime"/> — the same call the SQL Server dialect makes for
|
||||
/// <c>time</c>.</para>
|
||||
/// <para>Oracle's <c>DATE</c> carries a time-of-day component (it is not a date-only type), so
|
||||
/// <see cref="DriverDataType.DateTime"/> is the faithful mapping rather than a widening.</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;
|
||||
|
||||
// ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server
|
||||
// dialect does) keeps the arms readable as the names an operator sees in the browse tree.
|
||||
var family = sqlDataType.Trim().ToUpperInvariant();
|
||||
|
||||
// Prefix, not equality: the precision and time-zone qualifier live inside the family name.
|
||||
if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime;
|
||||
|
||||
return family switch
|
||||
{
|
||||
// One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are
|
||||
// resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are
|
||||
// here so a server or gateway that does surface one is not silently mapped to String.
|
||||
"NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION"
|
||||
or "INTEGER" or "INT" or "SMALLINT"
|
||||
or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64,
|
||||
|
||||
// The only single-precision storage type Oracle has.
|
||||
"BINARY_FLOAT" => DriverDataType.Float32,
|
||||
|
||||
"VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR"
|
||||
or "CLOB" or "NCLOB" => DriverDataType.String,
|
||||
|
||||
// Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as
|
||||
// NUMBER(1) or CHAR(1), which are invisible here and map by their own family.
|
||||
"BOOLEAN" => DriverDataType.Boolean,
|
||||
|
||||
"DATE" => DriverDataType.DateTime,
|
||||
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
|
||||
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
|
||||
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
|
||||
/// <c>information_schema.columns.data_type</c> actually reports.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
|
||||
/// </summary>
|
||||
public sealed class PostgresDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
|
||||
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
|
||||
/// a different object than it spells; rejecting it surfaces the config mistake instead.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 63;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Postgres;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => NpgsqlFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
|
||||
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
|
||||
/// catalog SQL below and the planner's markers can never drift apart.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> is matched against
|
||||
/// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps
|
||||
/// the parameter collection readable in a log and matches what the SQL text says.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
|
||||
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
|
||||
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
|
||||
/// untouched rather than throwing.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Postgres spells its row limit at the end of the statement, in
|
||||
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
|
||||
/// the seam carries both ends.
|
||||
/// </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>
|
||||
/// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
|
||||
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
|
||||
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
|
||||
/// account granted <c>USAGE</c> + <c>SELECT</c> on someone else's schema — the normal way a read-only
|
||||
/// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving
|
||||
/// the list from the relations the principal can see answers the question the catalog gate is really
|
||||
/// asking: "which schemas hold something I may read?"</para>
|
||||
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
|
||||
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
|
||||
/// <c>pg_toast_temp_N</c> namespaces are excluded by prefix as well: they hold storage internals and
|
||||
/// session-local temp objects, can never contain an authored poll target, and a <c>pg_temp_N</c> in
|
||||
/// particular names a different thing per session — offering it in the browse tree would present a
|
||||
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
|
||||
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
|
||||
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " +
|
||||
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
|
||||
"AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " +
|
||||
"AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " +
|
||||
"ORDER BY \"TABLE_SCHEMA\"";
|
||||
|
||||
/// <summary>
|
||||
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
|
||||
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT current_schema()";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
|
||||
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
|
||||
/// <c>SqlBrowseSession</c> both read every catalog column by name
|
||||
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> output
|
||||
/// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those
|
||||
/// consumers ask for rather than relying on a provider's case-insensitive <c>GetOrdinal</c>
|
||||
/// fallback.</para>
|
||||
/// <para>The <c>@schema</c> marker is written literally here because
|
||||
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
|
||||
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
|
||||
/// </remarks>
|
||||
public string ListTablesSql =>
|
||||
"SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " +
|
||||
"FROM information_schema.tables " +
|
||||
"WHERE table_schema = @schema ORDER BY \"TABLE_NAME\"";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one table in ordinal order — the same three columns
|
||||
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
|
||||
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
|
||||
/// </summary>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " +
|
||||
"is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " +
|
||||
"WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the SQL-standard escape rule
|
||||
/// Postgres follows, and the only metacharacter that could terminate a quoted identifier early.
|
||||
/// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
|
||||
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
|
||||
/// against the other): 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="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
||||
/// BOM. Those last cannot escape the quotes, 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).</para>
|
||||
/// <para>NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string
|
||||
/// outright, so passing one through would fail the whole statement rather than just the one
|
||||
/// identifier.</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
|
||||
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
|
||||
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
|
||||
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
|
||||
/// read back out of the catalog.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The double-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid PostgreSQL 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 PostgreSQL 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 the quotes 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>The input is the canonical, spelled-out name Postgres reports</b> —
|
||||
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
|
||||
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
|
||||
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
|
||||
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
|
||||
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
|
||||
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
|
||||
/// arbitrary-precision, so a high-scale column 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>json</c>,
|
||||
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
|
||||
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
|
||||
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
|
||||
/// falls back rather than pretending binary is a scalar.</para>
|
||||
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
|
||||
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
|
||||
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
|
||||
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
|
||||
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
|
||||
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
|
||||
/// <c>uniqueidentifier</c> is handled.</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
|
||||
{
|
||||
"boolean" or "bool" => DriverDataType.Boolean,
|
||||
"smallint" or "int2" => DriverDataType.Int16,
|
||||
"integer" or "int" or "int4" => DriverDataType.Int32,
|
||||
"bigint" or "int8" => DriverDataType.Int64,
|
||||
"real" or "float4" => DriverDataType.Float32,
|
||||
"double precision" or "float8"
|
||||
or "numeric" or "decimal" or "money" => DriverDataType.Float64,
|
||||
"text" or "character varying" or "varchar" or "character" or "char" or "bpchar"
|
||||
or "name" or "uuid" => DriverDataType.String,
|
||||
"date"
|
||||
or "timestamp without time zone" or "timestamp with time zone"
|
||||
or "timestamp" or "timestamptz" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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,
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the default provider. 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>
|
||||
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL markers are <c>@</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>"@k0"</c>.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
|
||||
/// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
|
||||
/// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
|
||||
/// collection readable in a profiler trace and matches what the SQL text says.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>SqlCommand</c> binds by name out of the box, so there is nothing SQL Server needs set on a
|
||||
/// fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>).
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
|
||||
+6
-1
@@ -21,8 +21,13 @@
|
||||
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
|
||||
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
|
||||
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type.
|
||||
PostgresDialect and OracleDialect land here for the same reason, each returning its
|
||||
provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully
|
||||
managed ADO.NET drivers, so adding them costs no native client install anywhere. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
|
||||
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// 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>
|
||||
/// <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.
|
||||
/// The bind names come from <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
|
||||
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
|
||||
/// Server, <c>:p_schema</c> on Oracle).</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
|
||||
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||
/// </summary>
|
||||
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
var schemas = await QueryColumnAsync(
|
||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
||||
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
|
||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
||||
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
|
||||
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||
{
|
||||
tables = await QueryColumnAsync(
|
||||
connection, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, "@schema", schema),
|
||||
connection, dialect, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
tablesBySchema[schema] = tables;
|
||||
}
|
||||
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
|
||||
if (columnsByTable.ContainsKey(key)) continue;
|
||||
|
||||
columnsByTable[key] = await QueryColumnAsync(
|
||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.DefaultSchemaSql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
string sql,
|
||||
string columnName,
|
||||
Action<DbCommand> bind,
|
||||
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
bind(command);
|
||||
|
||||
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
|
||||
|
||||
/// <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>
|
||||
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
|
||||
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
|
||||
/// <see cref="SqlProvider.Odbc"/> remain reserved names on the shipped enum; authoring one is a config
|
||||
/// mistake that must fail at construction with a message that says so, rather than silently degrading
|
||||
/// to another backend's SQL dialect against a database that does not speak it.</para>
|
||||
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
|
||||
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
|
||||
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
|
||||
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
|
||||
=> provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
|
||||
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
|
||||
$"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
|
||||
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
|
||||
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Uniformly applied at every command site, even where this statement takes no
|
||||
// parameters — a provider hook that is only *sometimes* honoured is the harder bug.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -161,11 +164,37 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
|
||||
/// <summary>
|
||||
/// Test seam over <see cref="DefaultDialectFor"/>. The parameterless constructor is the only
|
||||
/// production path into that switch, and <see cref="ForTest"/> deliberately bypasses it — so without
|
||||
/// this seam the gate has no direct coverage at all, which is how it kept a SqlServer-only switch
|
||||
/// after the factory and browser were widened.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider whose dialect to construct.</param>
|
||||
/// <returns>The dialect this build constructs for <paramref name="provider"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">The provider is reserved and not constructed.</exception>
|
||||
internal static ISqlDialect DefaultDialectForTest(SqlProvider provider) => DefaultDialectFor(provider);
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the production dialect for a provider — <b>the same set
|
||||
/// <see cref="SqlDriverFactoryExtensions"/> constructs</b>, because Test Connect must exercise the
|
||||
/// backend a Deploy would actually use. This build constructs <see cref="SqlProvider.SqlServer"/>,
|
||||
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>;
|
||||
/// <see cref="SqlProvider.MySql"/> and <see cref="SqlProvider.Odbc"/> stay reserved.
|
||||
/// <para><b>Keep this arm-for-arm with the factory's <c>CreateDialect</c>.</b> When the factory and the
|
||||
/// browser were widened to Postgres + Oracle this switch was left SqlServer-only, so a config that
|
||||
/// deployed and browsed fine failed Test Connect with an <see cref="InvalidOperationException"/> — and
|
||||
/// no test noticed, because every probe test injects its dialect through <see cref="ForTest"/>.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
|
||||
$"provider '{provider}' is not available in this build. Supported: " +
|
||||
$"'{nameof(SqlProvider.SqlServer)}', '{nameof(SqlProvider.Postgres)}', " +
|
||||
$"'{nameof(SqlProvider.Oracle)}'. Reserved (declared on the enum but not implemented): " +
|
||||
$"'{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// <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>
|
||||
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
|
||||
/// (<c>@k0</c> on SQL Server, <c>:p_k0</c> on Oracle) — with the value carried in
|
||||
/// <see cref="SqlQueryPlan.Parameters"/>. There is no interpolation of tag content into SQL anywhere in
|
||||
/// this type.</para>
|
||||
/// <para><b>The base names are this planner's, the spelling is the dialect's.</b> Each plan carries the
|
||||
/// markers it emitted <em>and</em> the matching <see cref="ISqlDialect.ParameterBindName"/>s, so the
|
||||
/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
|
||||
/// ODP.NET (<c>:p_k0</c> in the text, bare <c>p_k0</c> on the parameter).</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 base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
|
||||
private const string KeyBaseNamePrefix = "k";
|
||||
|
||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorMarker = "@w";
|
||||
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorBaseName = "w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
|
||||
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 markers = new List<string>(members.Count);
|
||||
var bindNames = new List<string>(members.Count);
|
||||
var values = new List<object?>(members.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
|
||||
var keyValue = member.KeyValue
|
||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||
if (!seen.Add(keyValue)) continue;
|
||||
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
|
||||
var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture);
|
||||
markers.Add(dialect.ParameterMarker(baseName));
|
||||
bindNames.Add(dialect.ParameterBindName(baseName));
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", names).Append(')')
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
|
||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||
dialect.SingleRowLimitSuffix);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
|
||||
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before any parameter is added: on ODP.NET this is what turns bind-by-position off, and
|
||||
// a positionally-bound IN list feeds one tag's value to another tag's node in silence.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = plan.SqlText;
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
for (var i = 0; i < plan.ParameterNames.Count; i++)
|
||||
for (var i = 0; i < plan.ParameterBindNames.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];
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
|
||||
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
|
||||
parameter.ParameterName = plan.ParameterBindNames[i];
|
||||
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// 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>
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
|
||||
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
|
||||
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
|
||||
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
|
||||
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</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>
|
||||
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||
/// convention.
|
||||
/// </param>
|
||||
/// <param name="ParameterBindNames">
|
||||
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
|
||||
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
|
||||
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
|
||||
/// </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.
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
|
||||
/// 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
|
||||
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
|
||||
object GroupKey,
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterNames,
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
|
||||
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
||||
|
||||
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
|
||||
|
||||
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
||||
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
||||
|
||||
|
||||
+5
-2
@@ -18,10 +18,13 @@
|
||||
<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). *@
|
||||
@* SqlServer, Postgres and Oracle construct a dialect; MySql/Odbc stay reserved
|
||||
SqlProvider members and are deliberately not offered (authoring one fails the driver). *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
|
||||
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
|
||||
<div class="form-text">SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
|
||||
+60
-20
@@ -1,9 +1,16 @@
|
||||
@* FOCAS address picker:
|
||||
1. Static parameter group + parameter ID → axis:5 (always available).
|
||||
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
||||
(PARAM:1020) and macro variable (MACRO:500) — always available.
|
||||
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||
device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
|
||||
seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
|
||||
runs an UntilStable settle loop; the _opening spinner covers the wait. *@
|
||||
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
||||
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
||||
post-connect and the browser runs an UntilStable settle loop; the _opening spinner covers
|
||||
the wait.
|
||||
|
||||
Both branches now emit an address FocasAddress.TryParse accepts. Branch 1 used to offer
|
||||
axis/spindle/program/status "parameter groups", which are not FOCAS addressing spaces, and
|
||||
emitted "axis:5"; branch 2 committed the browse leaf's host-prefixed full reference. Neither
|
||||
could be read (Gitea #540). *@
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||
@@ -14,20 +21,43 @@
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Parameter group</label>
|
||||
<select class="form-select form-select-sm" @bind="_group" @bind:after="OnChangedAsync">
|
||||
<option value="axis">axis</option>
|
||||
<option value="spindle">spindle</option>
|
||||
<option value="program">program</option>
|
||||
<option value="status">status</option>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Address space</label>
|
||||
<select class="form-select form-select-sm" @bind="_kind" @bind:after="OnChangedAsync">
|
||||
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
||||
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
||||
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_kind == FocasManualAddressKind.Pmc)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_pmcLetter" @bind:after="OnChangedAsync">
|
||||
@foreach (var letter in FocasAddressBuilder.PmcLetters)
|
||||
{
|
||||
<option value="@letter">@letter</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Parameter ID</label>
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_parameterId" @bind:after="OnChangedAsync" />
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
@if (_kind != FocasManualAddressKind.Macro)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">
|
||||
Bit <span class="text-muted small">(optional, @(_kind == FocasManualAddressKind.Pmc ? "0-7" : "0-31"))</span>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
max="@(_kind == FocasManualAddressKind.Pmc ? 7 : 31)"
|
||||
placeholder="whole word"
|
||||
@bind="_bitIndex" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_canBrowse)
|
||||
@@ -68,7 +98,14 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-muted small">Result:</span>
|
||||
<code class="mono ms-2">@_built</code>
|
||||
@if (string.IsNullOrEmpty(_built))
|
||||
{
|
||||
<span class="chip chip-bad ms-2">not a valid address</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code class="mono ms-2">@_built</code>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -79,8 +116,10 @@
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private string _group = "axis";
|
||||
private int _parameterId = 0;
|
||||
private FocasManualAddressKind _kind = FocasManualAddressKind.Pmc;
|
||||
private string _pmcLetter = "R";
|
||||
private int _number = 0;
|
||||
private int? _bitIndex;
|
||||
private string _built = "";
|
||||
|
||||
private Guid _token = Guid.Empty;
|
||||
@@ -94,7 +133,7 @@
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
@@ -125,8 +164,9 @@
|
||||
|
||||
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||
{
|
||||
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the
|
||||
// driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
|
||||
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the driver
|
||||
// FullName, which for a fixed-tree leaf is its canonical FIXED: address (Gitea #540); the
|
||||
// manual builder composes only the three addressed spaces and cannot reconstruct it, so
|
||||
// commit it directly rather than through OnChangedAsync.
|
||||
if (node.Kind != BrowseNodeKind.Leaf) return;
|
||||
_built = node.NodeId;
|
||||
@@ -135,7 +175,7 @@
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
|
||||
+68
-8
@@ -1,16 +1,76 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helper that converts a FOCAS parameter group + parameter ID
|
||||
/// into the canonical address string (e.g. axis:5).
|
||||
/// The addressing spaces the manual FOCAS picker can compose. Mirrors the parseable subset of
|
||||
/// <see cref="FocasAreaKind"/>: <see cref="FocasAreaKind.Fixed"/> is deliberately absent because a
|
||||
/// fixed-tree path is discovered from the machine, not composed from parts — the picker's browse
|
||||
/// tree supplies those.
|
||||
/// </summary>
|
||||
public enum FocasManualAddressKind
|
||||
{
|
||||
/// <summary>PMC area — letter + byte + optional bit.</summary>
|
||||
Pmc,
|
||||
|
||||
/// <summary>CNC parameter number + optional bit.</summary>
|
||||
Parameter,
|
||||
|
||||
/// <summary>Macro variable number.</summary>
|
||||
Macro,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helper that composes a canonical FOCAS address from the manual picker's parts.
|
||||
/// Extracted so unit tests can call it without bUnit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It builds through <see cref="FocasAddress"/> rather than formatting a string</b>, so what
|
||||
/// it emits is parseable by construction and the two cannot drift.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It previously emitted <c>$"{group}:{parameterId}"</c> over a group list of
|
||||
/// <c>axis/spindle/program/status</c> — none of which is a FOCAS addressing space — so the
|
||||
/// picker's own default value, <c>axis:5</c>, was an address the driver rejects. Together with
|
||||
/// the browse-commit defect this meant every UI-assisted authoring path produced unusable tags,
|
||||
/// and only hand-typed addresses worked at all (Gitea #540).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAddressBuilder
|
||||
{
|
||||
/// <summary>Builds the canonical FOCAS address string from a parameter group and parameter ID.</summary>
|
||||
/// <param name="group">The FOCAS parameter group (e.g. <c>"axis"</c>).</param>
|
||||
/// <param name="parameterId">The parameter ID within the group.</param>
|
||||
/// <returns>The canonical address string in <c>group:parameterId</c> form (e.g. <c>axis:5</c>).</returns>
|
||||
public static string Build(string group, int parameterId)
|
||||
=> $"{group}:{parameterId}";
|
||||
/// <summary>The PMC area letters the driver accepts, in the order the picker offers them.</summary>
|
||||
public static IReadOnlyList<string> PmcLetters { get; } =
|
||||
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
||||
|
||||
/// <summary>
|
||||
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
||||
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
||||
/// </summary>
|
||||
/// <param name="kind">Which addressing space the address is in.</param>
|
||||
/// <param name="pmcLetter">The PMC area letter; ignored unless <paramref name="kind"/> is
|
||||
/// <see cref="FocasManualAddressKind.Pmc"/>.</param>
|
||||
/// <param name="number">The PMC byte, parameter number or macro number.</param>
|
||||
/// <param name="bitIndex">Optional bit index — 0–7 for PMC, 0–31 for a parameter. Null for a
|
||||
/// whole-word address; always ignored for a macro, which has no bit form.</param>
|
||||
/// <returns>The canonical address, or an empty string when the parts are not addressable.</returns>
|
||||
public static string Build(
|
||||
FocasManualAddressKind kind, string? pmcLetter, int number, int? bitIndex)
|
||||
{
|
||||
FocasAddress? candidate = kind switch
|
||||
{
|
||||
FocasManualAddressKind.Pmc => new FocasAddress(
|
||||
FocasAreaKind.Pmc, (pmcLetter ?? "").Trim().ToUpperInvariant(), number, bitIndex),
|
||||
FocasManualAddressKind.Parameter => new FocasAddress(
|
||||
FocasAreaKind.Parameter, null, number, bitIndex),
|
||||
FocasManualAddressKind.Macro => new FocasAddress(
|
||||
FocasAreaKind.Macro, null, number, null),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Round-trip rather than trust the parts: the record accepts any values, the parser is the
|
||||
// authority on which of them address anything, and an empty result is what the picker shows
|
||||
// as "no address yet" instead of a plausible-looking string the driver would silently skip.
|
||||
return candidate?.Canonical is { } text && FocasAddress.TryParse(text) is not null ? text : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,32 @@ public sealed class FocasTagConfigModel
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||
/// <remarks>
|
||||
/// <b>This runs the driver's real parser, not a non-empty check</b> (Gitea #540). It used to
|
||||
/// accept any non-blank string, which is how 20 tags carrying a browse full reference
|
||||
/// (<c>10.201.31.5:8193/Identity/SeriesNumber</c>) were authored, deployed and sealed green —
|
||||
/// the driver then skipped every one at init and they read <c>BadNodeIdUnknown</c> forever
|
||||
/// with no signal anywhere. An address that cannot be parsed cannot be polled, so the
|
||||
/// rejection belongs here, where the operator is still looking at the field.
|
||||
/// <para><see cref="FocasAddress"/> lives in the driver's <c>Contracts</c> assembly precisely
|
||||
/// so this project can call it without referencing the runtime driver (Gitea #526).</para>
|
||||
/// </remarks>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
|
||||
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The operator-facing rejection message for an address the FOCAS parser will not accept,
|
||||
/// naming the four legal forms. Shared with the deploy-time inspector so the modal and the
|
||||
/// deploy gate say the same thing about the same string.
|
||||
/// </summary>
|
||||
/// <param name="address">The rejected address.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string DescribeUnparseableAddress(string address) =>
|
||||
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
|
||||
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — MTConnect is read-only, so
|
||||
/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
|
||||
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — reads and writes both address
|
||||
/// the DataItem by id, so there is no address encoding beyond it); the remaining fields are probe-sourced display
|
||||
/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
|
||||
/// load→save.</summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>FIXED:</c> address form added by Gitea #540, and the guarantee that adding it did not
|
||||
/// disturb the three forms that were already there.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAddressFixedFormTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber", "Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition", "Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds", "Timers/PowerOnSeconds")]
|
||||
[InlineData("FIXED:Spindle/S1/Load", "Spindle/S1/Load")]
|
||||
[InlineData("fixed:Program/Name", "Program/Name")] // prefix is case-insensitive
|
||||
[InlineData(" FIXED:Program/Name ", "Program/Name")] // outer whitespace tolerated
|
||||
public void Parses_a_fixed_tree_path(string input, string expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
parsed.FixedPath.ShouldBe(expectedPath);
|
||||
parsed.Canonical.ShouldBe("FIXED:" + expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FIXED:")] // no path
|
||||
[InlineData("FIXED: ")]
|
||||
[InlineData("FIXED:/Identity/Series")] // leading separator
|
||||
[InlineData("FIXED:Identity/Series/")] // trailing separator
|
||||
[InlineData("FIXED:Identity//Series")] // empty segment
|
||||
[InlineData("FIXED:Identity/ /Series")] // blank segment
|
||||
[InlineData("FIXED:MACRO:500")] // a colon would make the grammar ambiguous
|
||||
public void Rejects_a_path_that_is_not_addressable(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The exact strings the defect produced. Each names a real fixed-tree field but carries the
|
||||
/// device host, which is the tag's Device row's job — so they must still be rejected rather
|
||||
/// than quietly accepted with the host treated as a path segment.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")] // the manual picker's old default
|
||||
public void Still_rejects_what_the_defect_authored(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// <c>F</c> is a valid PMC area letter, so the <c>FIXED:</c> prefix has to be tested before
|
||||
/// the PMC branch. Both forms must survive that ordering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_fixed_prefix_does_not_shadow_the_pmc_F_area()
|
||||
{
|
||||
FocasAddress.TryParse("F20.3")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
FocasAddress.TryParse("F20.3")!.PmcLetter.ShouldBe("F");
|
||||
FocasAddress.TryParse("FIXED:Program/Name")!.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
public void The_pre_existing_forms_still_round_trip(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldNotBe(FocasAreaKind.Fixed);
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>The one place the <c>FIXED:</c> spelling is built, so emitter and parser cannot drift.</summary>
|
||||
[Fact]
|
||||
public void ForFixedPath_produces_what_TryParse_accepts()
|
||||
{
|
||||
var built = FocasAddress.ForFixedPath("Identity/SeriesNumber");
|
||||
|
||||
built.ShouldBe("FIXED:Identity/SeriesNumber");
|
||||
FocasAddress.TryParse(built)!.FixedPath.ShouldBe("Identity/SeriesNumber");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540 — authoring a fixed-tree tag through <c>/raw</c> → Browse device… produced a tag
|
||||
/// the driver could never read. The browse leaf's full reference was <c>{deviceHost}/{path}</c>,
|
||||
/// the commit mapper wrote it verbatim into <c>TagConfig.address</c>, and
|
||||
/// <see cref="FocasAddress.TryParse"/> accepts no such form — so the driver skipped the tag at
|
||||
/// init and every read returned <c>BadNodeIdUnknown</c>, indefinitely, while the deploy sealed
|
||||
/// green and driver health stayed Healthy with zero errors.
|
||||
/// <para>
|
||||
/// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two
|
||||
/// hand-authored <c>MACRO:</c> tags on the same device read Good.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasFixedTreeAddressingTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(
|
||||
FakeFocasClientFactory factory,
|
||||
bool fixedTreeEnabled,
|
||||
params FocasTagDefinition[] tags) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTreeEnabled },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
}, "drv-1", factory);
|
||||
|
||||
private static async Task<bool> WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return true;
|
||||
await Task.Delay(25, TestContext.Current.CancellationToken);
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The headline regression, stated over the WHOLE emitted tree rather than a sample: nothing
|
||||
/// the browser can offer is unauthorable. Sampling is what let this ship — the tree looked
|
||||
/// right, and no test ever asked whether the strings hanging off it were addresses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Every_browse_emitted_leaf_carries_an_address_the_driver_can_parse()
|
||||
{
|
||||
var drv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue("the fixed-tree cache never filled, so the tree under test was empty");
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldNotBeEmpty();
|
||||
foreach (var (browseName, info) in builder.Variables)
|
||||
{
|
||||
FocasAddress.TryParse(info.FullName)
|
||||
.ShouldNotBeNull($"leaf '{browseName}' emitted '{info.FullName}', which is not an address");
|
||||
// The specific shape that broke it: the device host leaking into the address, where it
|
||||
// is both unparseable and a second routing channel competing with the tag's Device row.
|
||||
info.FullName.ShouldNotContain("10.0.0.5");
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end-to-end claim the issue is about: take what the browser offers, author it as a tag,
|
||||
/// and read it. Anything short of this passed before the fix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_leaf_authored_from_the_browse_tree_reads_its_value()
|
||||
{
|
||||
// 1. Browse.
|
||||
var browseDrv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
|
||||
await browseDrv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => browseDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
var builder = new RecordingBuilder();
|
||||
await browseDrv.DiscoverAsync(builder, CancellationToken.None);
|
||||
await browseDrv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var seriesLeaf = builder.Variables.Single(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
|
||||
|
||||
// 2. Commit it as a raw tag, exactly as the browse-commit mapper does — the leaf's full
|
||||
// reference becomes the tag's address, and its RawPath is where the operator put it.
|
||||
var runDrv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition(
|
||||
"focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber",
|
||||
Host,
|
||||
seriesLeaf.Info.FullName,
|
||||
FocasDataType.String,
|
||||
Writable: false));
|
||||
await runDrv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => runDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
// 3. Read it by RawPath, which is the reference the v3 server uses.
|
||||
var read = await runDrv.ReadAsync(["focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber"], CancellationToken.None);
|
||||
|
||||
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
read[0].Value.ShouldBe("30i"); // FakeFocasClient.SysInfo.Series
|
||||
await runDrv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>FixedTree.Enabled</c> defaults to false and the universal browser force-enables it only
|
||||
/// for the duration of a browse. Without this, an operator would browse the tree, commit
|
||||
/// leaves, deploy, and land on a driver whose poll loop never starts — every tag Bad, with a
|
||||
/// perfectly correct address. Authoring a FIXED: tag is the request for the tree.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_authored_fixed_tag_arms_the_poll_loop_with_the_config_flag_off()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: false,
|
||||
new FocasTagDefinition("raw/Program", Host, "FIXED:Program/Name", FocasDataType.String, Writable: false));
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue("an authored FIXED: tag must start the fixed-tree loop on its own");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A driver with no fixed-tree tags and the flag off must still not start the loop.</summary>
|
||||
[Fact]
|
||||
public async Task An_ordinary_tag_does_not_arm_the_poll_loop()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: false,
|
||||
new FocasTagDefinition("raw/Run", Host, "R100", FocasDataType.Byte));
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
drv.GetDeviceState(Host)!.FixedTreeCache.ShouldBeNull();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path this machine does not expose is a genuine <c>BadNodeIdUnknown</c> — the address
|
||||
/// parsed, it simply names nothing here (a capability the series lacks, a renamed axis).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_fixed_path_the_machine_does_not_expose_reads_BadNodeIdUnknown()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition("raw/Ghost", Host, "FIXED:Axes/Q/AbsolutePosition", FocasDataType.Float64, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
var read = await drv.ReadAsync(["raw/Ghost"], CancellationToken.None);
|
||||
|
||||
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of machine
|
||||
/// state. Refused before a connection is opened.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_fixed_tree_tag_refuses_writes()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
// Writable:true on purpose — the refusal must come from the address kind, not from the
|
||||
// tag's own flag, or an operator could author their way past it.
|
||||
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: true));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("raw/Series", "nope")], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dead CNC must not keep serving its last fixed-tree values as Good. The poll loop
|
||||
/// swallows a failed tick by design (LogDebug + retry), so nothing else marks the caches
|
||||
/// stale — and unlike an area read, a fixed-tree read issues no traffic that could fail.
|
||||
/// This was harmless while nothing could bind to the tree; it is not harmless now.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Fixed_tree_values_go_bad_once_the_device_stops_answering()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
(await drv.ReadAsync(["raw/Series"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
// Age the last answered poll past the staleness budget. Reaching into DeviceState rather
|
||||
// than sleeping keeps this deterministic and instant; the budget itself is asserted below.
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
state.FixedTreeCts?.Cancel();
|
||||
state.LastFixedPollUtc = DateTime.UtcNow
|
||||
- FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(250))
|
||||
- TimeSpan.FromSeconds(1);
|
||||
|
||||
var stale = await drv.ReadAsync(["raw/Series"], CancellationToken.None);
|
||||
|
||||
stale[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The staleness budget tracks the poll cadence but never drops below 30 s, so an ordinarily
|
||||
/// slow CNC on the sub-second default cadence does not flap between Good and Bad.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(250, 30_000)] // default cadence — the 30 s floor wins
|
||||
[InlineData(1_000, 30_000)] // 8 s of cadence still under the floor
|
||||
[InlineData(10_000, 80_000)] // slow cadence — 8x the interval wins
|
||||
public void Stale_budget_is_eight_cadences_floored_at_thirty_seconds(int pollMs, int expectedMs)
|
||||
=> FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(pollMs))
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
|
||||
|
||||
/// <summary>Records what <c>DiscoverAsync</c> emits, flattening the folder tree.</summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
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 info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -25,12 +25,20 @@
|
||||
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
|
||||
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
|
||||
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
|
||||
EVENT, free text -> String fixture_program, fixture_block (never fed)
|
||||
EVENT, free text -> String fixture_program, fixture_block (never fed),
|
||||
fixture_writeback (never fed; write target)
|
||||
EVENT, DATA_SET representation -> String fixture_varset
|
||||
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
|
||||
|
||||
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
|
||||
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
|
||||
|
||||
fixture_writeback is likewise never fed, and exists for a different reason: it is the
|
||||
target the write legs POST to (agent.cfg has AllowPut = true). It is a SEPARATE item from
|
||||
fixture_block on purpose — the first successful write would take fixture_block off
|
||||
UNAVAILABLE for the life of the container and quietly rob the mapping test above of its
|
||||
subject. Because no adapter ever writes fixture_writeback, the value a write leg reads back
|
||||
out of /current can only have come from that write.
|
||||
-->
|
||||
<MTConnectDevices
|
||||
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
|
||||
@@ -99,6 +107,12 @@
|
||||
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
|
||||
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
|
||||
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
|
||||
<!--
|
||||
The write-back target (MTCONNECT_WRITE_TARGET overrides the id when the suite is
|
||||
pointed at a real Agent). Free-text EVENT, so any string a write leg sends is a
|
||||
legal value, and never fed by the adapter, so nothing races the assertion.
|
||||
-->
|
||||
<DataItem category="EVENT" id="fixture_writeback" name="Pwriteback" type="PROGRAM_COMMENT"/>
|
||||
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
|
||||
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
|
||||
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
|
||||
|
||||
@@ -22,8 +22,18 @@ BufferSize = 14
|
||||
CheckpointFrequency = 1000
|
||||
MaxAssets = 128
|
||||
|
||||
# MTConnect is a read-only source for this driver; the Agent must not accept writes.
|
||||
AllowPut = false
|
||||
# Writes ARE in scope for this fixture, because the driver now implements IWritable (see
|
||||
# docs/plans/2026-08-05-mtconnect-writeback-design.md). The mechanism is this very key: with
|
||||
# AllowPut on, POST {agent}/{device}?{dataItemId}={value} sets what the Agent REPORTS for that
|
||||
# observation. It is injection into the Agent's buffer -- visible to every consumer of the
|
||||
# Agent, overwritten by the next SHDR update for the same DataItem -- and never a command to a
|
||||
# machine. The integration suite's write legs (round-trip, agent refusal, disabled gate) need
|
||||
# it on; with the driver's own write.enabled off, the shipped read-only behaviour is unchanged.
|
||||
#
|
||||
# No AllowPutFrom is set, so any host that can reach the port may write. Acceptable for a
|
||||
# throwaway test Agent on the fixture network; NOT acceptable for a plant Agent, where the
|
||||
# allow-list is the second half of the gate.
|
||||
AllowPut = true
|
||||
|
||||
SchemaVersion = 2.0
|
||||
MonitorConfigFiles = false
|
||||
|
||||
+331
-7
@@ -55,14 +55,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
|
||||
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
|
||||
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
|
||||
/// <b>One thing this suite deliberately does NOT cover, with a reason.</b>
|
||||
/// <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
|
||||
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
|
||||
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
|
||||
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
|
||||
/// shape belongs to older/third-party Agents and stays canned-only.
|
||||
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
|
||||
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Writes ARE covered now</b> — the driver gained <c>IWritable</c> and the fixture Agent
|
||||
/// runs <c>AllowPut = true</c> to match (<c>Docker/agent.cfg</c>). Three legs: the round trip
|
||||
/// (a driver write the Agent then reports back), the Agent's own refusal of an undeclared
|
||||
/// DataItem mapping to <c>BadCommunicationError</c>, and the <c>write.enabled</c> gate
|
||||
/// refusing with <c>BadNotWritable</c> without touching the Agent. The injected target is
|
||||
/// <c>fixture_writeback</c>, a DataItem the fixture's adapter never feeds, so an observed
|
||||
/// value can only have come from the write under test. A write is <b>observation
|
||||
/// injection</b>, not machine command: it sets what the Agent reports, which is exactly why
|
||||
/// a live Agent is the only thing that can prove it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(MTConnectAgentCollection.Name)]
|
||||
@@ -83,9 +92,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>The Agent answered and refused the write (non-2xx, or an <c>MTConnectError</c> under 200).</summary>
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>No DataItem this driver serves answers to the reference — decided locally, before the wire.</summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary><c>write.enabled</c> is off (or the DataItem is CONDITION-category): nothing is attempted.</summary>
|
||||
private const uint BadNotWritable = 0x803B0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the DataItem the write legs inject into, for a run pointed at a real Agent whose
|
||||
/// model has no <c>fixture_writeback</c>.
|
||||
/// </summary>
|
||||
private const string WriteTargetEnvVar = "MTCONNECT_WRITE_TARGET";
|
||||
|
||||
/// <summary>
|
||||
/// The fixture's dedicated write target: a free-text EVENT the SHDR adapter NEVER feeds, so a
|
||||
/// value observed on it can only have come from the write under test. Deliberately NOT
|
||||
/// <c>fixture_block</c> — the first successful write would take that item off
|
||||
/// <c>UNAVAILABLE</c> for the life of the container and rob
|
||||
/// <see cref="Read_maps_an_unavailable_observation_to_BadNoCommunication"/> of its subject.
|
||||
/// </summary>
|
||||
private const string DefaultWriteTargetId = "fixture_writeback";
|
||||
|
||||
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
|
||||
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
|
||||
|
||||
/// <summary>
|
||||
/// How long a write leg waits for the Agent to report the injected observation on
|
||||
/// <c>/current</c>. A PUT lands in the Agent's buffer synchronously, so this is slack for the
|
||||
/// HTTP round trips rather than for any processing the Agent has to schedule.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan WriteVisibleWait = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
// ---- reachability ----
|
||||
@@ -537,6 +577,169 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- writes: the Agent's AllowPut surface ----
|
||||
|
||||
/// <summary>
|
||||
/// <b>The write round trip, and the only thing that can prove the PUT surface at all.</b> A
|
||||
/// driver write must be accepted by the Agent and must then be what the Agent
|
||||
/// <i>reports</i> for that DataItem — read back off <c>/current</c> over plain HTTP (not
|
||||
/// through the parser under test), and then through the driver's own read path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The value is unique per run</b> (a <see cref="Environment.TickCount64"/> suffix).
|
||||
/// The Agent keeps an injected observation for the life of the container, so a constant
|
||||
/// would be satisfied by a previous run's leftover and the test would pass against a
|
||||
/// driver whose write never left the process.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The target is a DataItem the SHDR adapter never feeds</b>, so no observation but
|
||||
/// this write can put a value on it. Ring-buffer eviction is not a hazard here even
|
||||
/// though the fixture's stream is busy: <c>BufferSize = 14</c> in <c>agent.cfg</c> is an
|
||||
/// exponent (2^14 = 16384 observations), and <c>/current</c> is answered from the
|
||||
/// Agent's checkpoint — the newest observation per DataItem — which survives the ring
|
||||
/// wrapping past it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task Write_injects_an_observation_the_agent_then_reports()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
var target = ResolveWriteTarget();
|
||||
if (target is null)
|
||||
{
|
||||
Assert.Skip(NoWriteTargetReason());
|
||||
}
|
||||
|
||||
var (dataItemId, deviceName) = target!.Value;
|
||||
var value = $"OTOPCUA-WRITE-{Environment.TickCount64:X}";
|
||||
|
||||
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)], writeEnabled: true);
|
||||
|
||||
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.ShouldBe(
|
||||
Good,
|
||||
$"the Agent at {agent.AgentUri} must accept a write to DataItem '{dataItemId}' on device " +
|
||||
$"'{deviceName}'. A refusal here is most often AllowPut still false in the Agent's config.");
|
||||
|
||||
var observed = await WaitForObservationAsync(dataItemId, value, WriteVisibleWait);
|
||||
observed.ShouldBe(
|
||||
value,
|
||||
$"the Agent must REPORT the injected value on /current within {WriteVisibleWait.TotalSeconds:F0}s; " +
|
||||
"a Good write whose value never appears is a write that went nowhere");
|
||||
|
||||
var reads = await live.Driver.ReadAsync([dataItemId], Ct);
|
||||
reads.Count.ShouldBe(1);
|
||||
reads[0].StatusCode.ShouldBe(Good, "the injected observation must read back through the driver's own path");
|
||||
reads[0].Value.ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The Agent's own refusal, mapped to <c>BadCommunicationError</c>.</b> A write naming a
|
||||
/// DataItem the Agent does not declare reaches the wire and comes back refused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this reaches the Agent at all, when an unknown reference is normally answered
|
||||
/// locally.</b> <c>BuildWriteTargets</c> derives a write target for every DataItem the
|
||||
/// <i>binding</i> serves — i.e. every authored tag — resolving the device from the probe
|
||||
/// model and falling back to the configured <c>deviceName</c>. So an authored tag naming
|
||||
/// an id the Agent never declared, on a driver whose <c>deviceName</c> IS configured,
|
||||
/// resolves to a real URL and is POSTed. That is precisely the shape a stale tag has after
|
||||
/// a machine's DataItem is renamed, and the Agent's answer to it is the refusal this leg
|
||||
/// pins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The second half is the falsifiability control</b>, and without it the leg proves
|
||||
/// nothing: the SAME unknown id on a driver with NO configured device name has no URL to
|
||||
/// POST to and must be refused locally as <c>BadNodeIdUnknown</c>. Two different codes for
|
||||
/// the same id is what shows the first write actually went to the Agent rather than being
|
||||
/// short-circuited into a look-alike failure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task Write_to_a_data_item_the_agent_does_not_declare_reports_the_agents_refusal()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
var target = ResolveWriteTarget();
|
||||
if (target is null)
|
||||
{
|
||||
Assert.Skip(NoWriteTargetReason());
|
||||
}
|
||||
|
||||
var deviceName = target!.Value.DeviceName;
|
||||
var unknownId = $"otopcua_no_such_data_item_{Environment.TickCount64:X}";
|
||||
|
||||
await using (var live = await LiveDriverAsync(
|
||||
[Tag(unknownId, DriverDataType.String)], writeEnabled: true, deviceName: deviceName))
|
||||
{
|
||||
var refused = await live.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
|
||||
|
||||
refused.Count.ShouldBe(1);
|
||||
refused[0].StatusCode.ShouldBe(
|
||||
BadCommunicationError,
|
||||
$"the Agent at {agent.AgentUri} must refuse a write to DataItem '{unknownId}', which device " +
|
||||
$"'{deviceName}' does not declare, and the driver must report that refusal as an Agent " +
|
||||
"failure rather than as success");
|
||||
}
|
||||
|
||||
await using var noDevice = await LiveDriverAsync([Tag(unknownId, DriverDataType.String)], writeEnabled: true);
|
||||
|
||||
var local = await noDevice.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
|
||||
|
||||
local.Count.ShouldBe(1);
|
||||
local[0].StatusCode.ShouldBe(
|
||||
BadNodeIdUnknown,
|
||||
"with no device resolvable there is no URL to POST to, so the refusal must be decided locally — " +
|
||||
"and a run where BOTH writes report the same code proves nothing about the Agent");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The driver-side gate, which is the half an operator controls.</b> With
|
||||
/// <c>write.enabled</c> absent the same write is refused <c>BadNotWritable</c> — and the
|
||||
/// Agent's <c>/current</c> is untouched, which is the assertion that distinguishes "refused"
|
||||
/// from "reported as refused and sent anyway".
|
||||
/// </summary>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task Write_is_refused_without_touching_the_agent_when_write_is_not_enabled()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
var target = ResolveWriteTarget();
|
||||
if (target is null)
|
||||
{
|
||||
Assert.Skip(NoWriteTargetReason());
|
||||
}
|
||||
|
||||
var dataItemId = target!.Value.DataItemId;
|
||||
var value = $"OTOPCUA-DISABLED-{Environment.TickCount64:X}";
|
||||
var before = await ObservationValueAsync(dataItemId);
|
||||
|
||||
// No writeEnabled: the driver's own gate, with the Agent's AllowPut left on.
|
||||
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)]);
|
||||
|
||||
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.ShouldBe(
|
||||
BadNotWritable,
|
||||
"write.enabled is absent, so the honest answer is 'this is not writable' — not a comms failure");
|
||||
|
||||
// Long enough that a write which HAD been dispatched would be visible: the round-trip leg
|
||||
// observes one inside WriteVisibleWait, and this waits for the same value to appear.
|
||||
var after = await WaitForObservationAsync(dataItemId, value, TimeSpan.FromSeconds(3));
|
||||
|
||||
after.ShouldNotBe(value, "a gated write must never reach the Agent's observation buffer");
|
||||
after.ShouldBe(
|
||||
before,
|
||||
$"nothing else feeds DataItem '{dataItemId}', so its reported value must be exactly what it was " +
|
||||
"before the gated write");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void SkipIfDown()
|
||||
@@ -569,13 +772,17 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
return port;
|
||||
}
|
||||
|
||||
private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
|
||||
private MTConnectDriverOptions LiveOptions(string? deviceName = null, bool writeEnabled = false) =>
|
||||
new()
|
||||
{
|
||||
AgentUri = agent.AgentUri,
|
||||
DeviceName = deviceName,
|
||||
RequestTimeoutMs = 15_000,
|
||||
|
||||
// Off unless a write leg asks for it: every other test in this suite must run against the
|
||||
// shipped read-only driver, whatever the fixture Agent's AllowPut says.
|
||||
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
|
||||
|
||||
// Faster than the 1000 ms default so a subscription sees several distinct chunks inside
|
||||
// StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
|
||||
SampleIntervalMs = 200,
|
||||
@@ -597,6 +804,10 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
sampleCount = options.SampleCount,
|
||||
heartbeatMs = options.HeartbeatMs,
|
||||
probe = new { enabled = false },
|
||||
|
||||
// Sourced from the options rather than a separate parameter, so the config the driver
|
||||
// parses can never disagree with the options object a test thinks it built.
|
||||
write = new { enabled = options.Write.Enabled },
|
||||
tags = (tags ?? []).Select(t => new
|
||||
{
|
||||
fullName = t.FullName,
|
||||
@@ -607,9 +818,16 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
|
||||
|
||||
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
|
||||
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
|
||||
/// <param name="tags">The authored tags, by DataItem id.</param>
|
||||
/// <param name="writeEnabled">The driver's own <c>write.enabled</c> gate; off by default.</param>
|
||||
/// <param name="deviceName">
|
||||
/// The configured device scope, and the write path's device fallback for a DataItem the probe
|
||||
/// model does not declare. Null (the default) leaves the driver unscoped.
|
||||
/// </param>
|
||||
private async Task<LiveDriver> LiveDriverAsync(
|
||||
IEnumerable<MTConnectTagDefinition> tags, bool writeEnabled = false, string? deviceName = null)
|
||||
{
|
||||
var options = LiveOptions();
|
||||
var options = LiveOptions(deviceName, writeEnabled);
|
||||
var driver = new MTConnectDriver(options, "mtconnect-live");
|
||||
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
|
||||
|
||||
@@ -650,6 +868,112 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
return XDocument.Parse(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DataItem the write legs inject into, and the device that owns it — or <c>null</c> when
|
||||
/// this Agent declares nothing suitable, which is a skip rather than a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolved from the Agent's own <c>/probe</c> like every other expectation in this suite, and
|
||||
/// overridable via <see cref="WriteTargetEnvVar"/> so a run pointed at a real machine tool can
|
||||
/// name a DataItem its operator is willing to have injected. CONDITION-category items and the
|
||||
/// TIME_SERIES / DATA_SET / TABLE representations are excluded because <c>WriteAsync</c>
|
||||
/// itself refuses them — pointing a write leg at one would assert the wrong refusal.
|
||||
/// </remarks>
|
||||
private (string DataItemId, string DeviceName)? ResolveWriteTarget()
|
||||
{
|
||||
var id = (Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId).Trim();
|
||||
|
||||
var declared = agent.DeclaredDataItems
|
||||
.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal));
|
||||
|
||||
if (declared is null ||
|
||||
Is(declared.Category, "CONDITION") ||
|
||||
Is(declared.Representation, "TIME_SERIES") ||
|
||||
Is(declared.Representation, "DATA_SET") ||
|
||||
Is(declared.Representation, "TABLE"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var device = OwningDeviceName(id);
|
||||
|
||||
return device is null ? null : (id, device);
|
||||
}
|
||||
|
||||
/// <summary>The skip message for a run whose Agent declares no usable write target.</summary>
|
||||
private string NoWriteTargetReason() =>
|
||||
$"The Agent at {agent.AgentUri} declares no writable DataItem " +
|
||||
$"'{Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId}' " +
|
||||
$"(a non-CONDITION, scalar item under a Device). Set {WriteTargetEnvVar} to a DataItem this " +
|
||||
"Agent declares and is safe to inject an observation into, or run against the fixture's own " +
|
||||
"Devices.xml, which carries a never-fed one for exactly this purpose.";
|
||||
|
||||
/// <summary>
|
||||
/// The device a DataItem is declared under, addressed the way the Agent's PUT URL addresses
|
||||
/// it: by <c>name</c>, falling back to <c>id</c> for a nameless device.
|
||||
/// </summary>
|
||||
private string? OwningDeviceName(string dataItemId)
|
||||
{
|
||||
var element = agent.ProbeDocument?
|
||||
.Descendants()
|
||||
.FirstOrDefault(e =>
|
||||
e.Name.LocalName == "DataItem" &&
|
||||
string.Equals((string?)e.Attribute("id"), dataItemId, StringComparison.Ordinal));
|
||||
|
||||
var device = element?.Ancestors().FirstOrDefault(a => a.Name.LocalName == "Device");
|
||||
var name = (string?)device?.Attribute("name") ?? (string?)device?.Attribute("id");
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? null : name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the Agent currently reports for one DataItem, straight off <c>/current</c> — the raw
|
||||
/// observation text (<c>UNAVAILABLE</c> included), or <c>null</c> when the snapshot carries no
|
||||
/// observation for it at all.
|
||||
/// </summary>
|
||||
private async Task<string?> ObservationValueAsync(string dataItemId) =>
|
||||
(await CurrentAsync())
|
||||
.Descendants()
|
||||
.Where(e => !e.HasElements)
|
||||
.Where(e => string.Equals((string?)e.Attribute("dataItemId"), dataItemId, StringComparison.Ordinal))
|
||||
.Select(e => e.Value.Trim())
|
||||
.LastOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Polls <c>/current</c> until the DataItem reports <paramref name="expected"/> or the wait
|
||||
/// runs out, returning whatever it last observed either way.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returning the last observation rather than a bool is deliberate: a failing assertion can
|
||||
/// then print what the Agent WAS reporting, which is the difference between "the write never
|
||||
/// landed" and "the write landed as something else" (a formatting defect).
|
||||
/// </remarks>
|
||||
private async Task<string?> WaitForObservationAsync(string dataItemId, string expected, TimeSpan timeout)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||||
deadline.CancelAfter(timeout);
|
||||
|
||||
string? observed;
|
||||
do
|
||||
{
|
||||
observed = await ObservationValueAsync(dataItemId);
|
||||
if (string.Equals(observed, expected, StringComparison.Ordinal))
|
||||
{
|
||||
return observed;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), deadline.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!Ct.IsCancellationRequested)
|
||||
{
|
||||
return observed;
|
||||
}
|
||||
}
|
||||
while (true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
|
||||
/// a read or subscription can make a non-vacuous statement about.
|
||||
|
||||
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every write this Agent was asked to take, in order — the record a driver-level write test
|
||||
/// asserts against (which DataItem, on which device, carrying what).
|
||||
/// </summary>
|
||||
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
|
||||
/// refusal to script the Agent's <c>MTConnectError</c> leg.
|
||||
/// </summary>
|
||||
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
|
||||
|
||||
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
||||
public Exception? WriteFailure { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Writes.Enqueue((deviceName, dataItemId, value));
|
||||
|
||||
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
||||
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
||||
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The wire contract of <see cref="MTConnectAgentClient.WriteObservationAsync"/> — the Agent's
|
||||
/// <c>AllowPut</c> surface. Everything here runs through a stubbed
|
||||
/// <see cref="HttpMessageHandler"/> via the internal test-seam constructor, so the verb, the
|
||||
/// exact request URI, and each response shape are asserted with no socket and no Agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The URI assertions are the point of this class, not incidental detail.</b> A write is
|
||||
/// nothing but a URL: get the escaping or the device segment wrong and the Agent still
|
||||
/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption
|
||||
/// with no error anywhere, visible only on a later read-back. There is no encoder to unit
|
||||
/// test, so this is the only place that shape is pinned.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The refusal legs pin the split the seam contract rests on:</b> an Agent that answered
|
||||
/// and said no is a returned outcome, while an Agent that could not be reached throws. The
|
||||
/// 200-carrying-an-<c>MTConnectError</c> case is why the status code alone can never decide
|
||||
/// it — that is the shape a real cppagent uses to report every objection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MTConnectAgentClientWriteTests
|
||||
{
|
||||
// ── stub handler ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||
respond(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>Records the requests it is handed, then answers each with <paramref name="respond"/>.</summary>
|
||||
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add((request.Method, request.RequestUri!));
|
||||
|
||||
return Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") =>
|
||||
new() { AgentUri = agentUri };
|
||||
|
||||
private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/plain"),
|
||||
};
|
||||
|
||||
private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(xml, Encoding.UTF8, "text/xml"),
|
||||
};
|
||||
|
||||
/// <summary>Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.</summary>
|
||||
private const string MTConnectErrorXml =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors>
|
||||
<Error errorCode="UNSUPPORTED">Cannot find device data item: nosuchitem</Error>
|
||||
</Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
// ── verb + URI ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_is_issued_as_a_POST()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options(), handler);
|
||||
|
||||
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
|
||||
|
||||
handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The device is a <b>path segment</b> and the DataItem id/value are a <b>query pair</b> —
|
||||
/// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts,
|
||||
/// because a fragment-wise assertion passes on a URI that is assembled in the wrong order.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_request_uri_is_the_agents_AllowPut_shape()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options(), handler);
|
||||
|
||||
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
|
||||
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A value carrying a space, an <c>&</c> and a <c>=</c> — each of which silently ends the
|
||||
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
|
||||
/// treatment. Unescaped, the Agent would take a 200 and store the truncated value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_dataItemId_and_value_are_percent_encoded()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options(), handler);
|
||||
|
||||
await client.WriteObservationAsync(
|
||||
"Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None);
|
||||
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe(
|
||||
"http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a
|
||||
/// trailing slash does not double up — the same left-part rule the read paths use.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler);
|
||||
|
||||
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||
.ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>deviceName</c> argument decides the path — <b>not</b> the options' device scope. A
|
||||
/// multi-device Agent is addressed per write, so an implementation that reused the read
|
||||
/// paths' scoped URI would write every DataItem onto one device.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" },
|
||||
handler);
|
||||
|
||||
await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None);
|
||||
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||
.ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi");
|
||||
}
|
||||
|
||||
// ── acceptance ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer
|
||||
/// as anything but acceptance would refuse every write that actually worked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_empty_200_is_accepted()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeTrue();
|
||||
outcome.AgentError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.</summary>
|
||||
[Fact]
|
||||
public async Task A_200_whose_body_is_not_an_error_document_is_accepted()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(), new StubHandler((_, _) => Task.FromResult(Ok("<html><body>OK</body></html>"))));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeTrue();
|
||||
outcome.AgentError.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── refusal ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The load-bearing refusal: HTTP 200 with an <c>MTConnectError</c> body.
|
||||
/// <c>EnsureSuccessStatusCode</c> never fires here, so an implementation that trusted the
|
||||
/// status code would report every rejected write as a success.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml))));
|
||||
|
||||
var outcome = await client.WriteObservationAsync(
|
||||
"StubDevice", "nosuchitem", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeFalse();
|
||||
outcome.AgentError.ShouldNotBeNull();
|
||||
outcome.AgentError.ShouldContain("UNSUPPORTED");
|
||||
outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <c>MTConnectError</c> document carrying no readable <c><Error></c> detail is
|
||||
/// still a refusal. The Agent said no; that it said so unhelpfully changes nothing. The trap
|
||||
/// this pins is that the natural implementation — "return the joined error text, or null if
|
||||
/// there is none" — hands back the same <c>null</c> that means "this body was not an error
|
||||
/// document at all", silently converting the refusal into an acceptance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_error_document_with_no_readable_detail_is_still_a_refusal()
|
||||
{
|
||||
const string HollowError =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors/>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(HollowError))));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeFalse();
|
||||
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>The same, for an <c><Error></c> element that is present but blank.</summary>
|
||||
[Fact]
|
||||
public async Task An_error_document_whose_error_element_is_blank_is_still_a_refusal()
|
||||
{
|
||||
const string BlankError =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Errors><Error> </Error></Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(BlankError))));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeFalse();
|
||||
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_success_status_is_a_refusal_naming_the_status()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(),
|
||||
new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent(string.Empty),
|
||||
})));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeFalse();
|
||||
outcome.AgentError.ShouldNotBeNull();
|
||||
outcome.AgentError.ShouldContain("404");
|
||||
}
|
||||
|
||||
// ── transport failure throws, never a false outcome ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// "Unreachable" must stay distinguishable from "rejected". Returning
|
||||
/// <c>Accepted = false</c> here would collapse a comms fault into a per-tag refusal, which
|
||||
/// the driver maps to a different status code and an operator resolves differently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal()
|
||||
{
|
||||
var boom = new HttpRequestException("dial failed");
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(), new StubHandler((_, _) => Task.FromException<HttpResponseMessage>(boom)));
|
||||
|
||||
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
|
||||
thrown.ShouldBeSameAs(boom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A frozen Agent must surface as a bounded <see cref="TimeoutException"/> naming the URI,
|
||||
/// not park the caller — the same per-call deadline discipline the read legs carry
|
||||
/// (arch-review R2-01).
|
||||
/// </summary>
|
||||
[Fact(Timeout = 10000)]
|
||||
public async Task A_frozen_agent_times_out_within_the_per_call_deadline()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 },
|
||||
new StubHandler(async (_, ct) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
|
||||
throw new UnreachableException();
|
||||
}));
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
|
||||
private sealed class UnreachableException : Exception;
|
||||
|
||||
/// <summary>Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new MTConnectAgentClient(
|
||||
Options(),
|
||||
new StubHandler(async (_, ct) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
|
||||
throw new UnreachableException();
|
||||
}));
|
||||
|
||||
var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token);
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => write);
|
||||
}
|
||||
|
||||
// ── disposal ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException()
|
||||
{
|
||||
var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
|
||||
client.Dispose();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,16 @@ public sealed class MTConnectDiscoverTests
|
||||
"dev1_system_cond",
|
||||
];
|
||||
|
||||
private static MTConnectDriverOptions Opts(string? deviceName = null) =>
|
||||
private static MTConnectDriverOptions Opts(string? deviceName = null, bool writeEnabled = false) =>
|
||||
new()
|
||||
{
|
||||
AgentUri = AgentUri,
|
||||
DeviceName = deviceName,
|
||||
|
||||
// Off unless a test says otherwise — the shipped default, and what every pre-write-back
|
||||
// expectation in this file was written against.
|
||||
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
|
||||
|
||||
// Discovery is a pure read of the /probe device model: it must enumerate what the Agent
|
||||
// declares, NOT what an operator has already authored. Left empty on purpose so a driver
|
||||
// that streamed its authored tag table instead could not pass.
|
||||
@@ -324,10 +328,10 @@ public sealed class MTConnectDiscoverTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
|
||||
/// browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
|
||||
/// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
|
||||
/// never inferred from a device model.
|
||||
/// Write-back is off by default, so every browsed leaf is
|
||||
/// <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only from OPC UA, and
|
||||
/// exactly what this driver shipped. Nothing is historized by discovery either: historization
|
||||
/// is authored per tag, never inferred from a device model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discover_streams_every_leaf_view_only_and_not_historized()
|
||||
@@ -341,6 +345,47 @@ public sealed class MTConnectDiscoverTests
|
||||
cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <c>write.enabled</c> on, the same probe model browses <see cref="SecurityClassification.Operate"/>
|
||||
/// — but only for the leaves <c>WriteAsync</c> would actually accept. A browse that advertised a
|
||||
/// tier the write path refuses is the "silent no-op in a new costume" failure MQTT's
|
||||
/// <c>CanWrite</c> pins against; the flag is the ONLY difference between this test and the one
|
||||
/// above, so a driver that ignored it could not pass both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discover_streams_writable_leaves_as_operate_when_write_is_enabled()
|
||||
{
|
||||
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: true));
|
||||
|
||||
var cap = await DiscoverAsync(driver);
|
||||
|
||||
var writable = cap.Variables.Where(v => !v.Attr.IsAlarm).ToList();
|
||||
|
||||
// Counted, not just ShouldAllBe: an empty sequence satisfies ShouldAllBe vacuously, so a
|
||||
// discovery that streamed nothing at all would otherwise pass this test.
|
||||
writable.Count.ShouldBe(FixtureDataItemIds.Length - 1);
|
||||
writable.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.Operate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A CONDITION data item stays <see cref="SecurityClassification.ViewOnly"/> in <b>both</b>
|
||||
/// modes: a condition PUT carries alarm semantics and a different <c>level|text</c> wire
|
||||
/// format, so the write path refuses it and the browse must say so.
|
||||
/// </summary>
|
||||
/// <param name="writeEnabled">Whether the driver's write-back block is switched on.</param>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Discover_streams_a_condition_leaf_view_only_whether_or_not_write_is_enabled(bool writeEnabled)
|
||||
{
|
||||
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: writeEnabled));
|
||||
|
||||
var cap = await DiscoverAsync(driver);
|
||||
|
||||
cap.Variables.Single(v => v.Attr.FullName == "dev1_system_cond")
|
||||
.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- browse names ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -56,6 +56,20 @@ public sealed class MTConnectDriverOptionsTests
|
||||
reconnect.BackoffMultiplier.ShouldBeGreaterThan(1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-back is <b>off unless authored</b>: the driver shipped read-only, and the agent's
|
||||
/// PUT surface is itself off by default, so a deployment that never mentions <c>write</c>
|
||||
/// must keep exactly today's behaviour.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_defaults_to_disabled()
|
||||
{
|
||||
var write = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Write;
|
||||
|
||||
write.ShouldNotBeNull();
|
||||
write.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="MTConnectDriverOptions.Tags"/> defaults to an <i>empty</i> collection, never
|
||||
/// <c>null</c> — a null collection here is an operator-authorable NullReferenceException at
|
||||
|
||||
@@ -207,19 +207,25 @@ public sealed class MTConnectFactoryTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
|
||||
/// instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
|
||||
/// would start offering an operation the protocol cannot honour.
|
||||
/// The other half of the capability pin: the instance IS <see cref="IWritable"/>, so the
|
||||
/// runtime's write gate dispatches to it. This is the seam the concrete return type exists for
|
||||
/// — the capability was added to the driver with no change to the factory, and a narrowed
|
||||
/// return would have left it implemented but never dispatched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Dispatchable is not enabled.</b> The config below authors no <c>write</c> block, so this
|
||||
/// instance refuses every write with <c>BadNotWritable</c> — and the Agent's own
|
||||
/// <c>AllowPut</c> is a second, independent gate. See <c>MTConnectWriteTests</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Registered_delegate_returns_an_instance_that_is_not_writable()
|
||||
public void Registered_delegate_returns_an_instance_that_dispatches_writes()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
MTConnectDriverFactoryExtensions.Register(registry);
|
||||
|
||||
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||
|
||||
(driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
|
||||
(driver is IWritable).ShouldBeTrue("MTConnect must dispatch writes (the Agent AllowPut surface)");
|
||||
}
|
||||
|
||||
/// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
|
||||
@@ -349,4 +355,51 @@ public sealed class MTConnectFactoryTests
|
||||
// above) hold for both entry points.
|
||||
MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>write</c> block reaches the options — the opt-in half of the two-layer gate on
|
||||
/// write-back (the agent's own <c>AllowPut</c> is the other). Authored <c>true</c> arms it;
|
||||
/// an authored <c>false</c> and an omitted block are the same thing, and both leave the
|
||||
/// driver read-only.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_section_round_trips_and_defaults_to_disabled_when_absent()
|
||||
{
|
||||
MTConnectDriver.ParseOptions(
|
||||
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
|
||||
.Write.Enabled.ShouldBeTrue();
|
||||
|
||||
MTConnectDriver.ParseOptions(
|
||||
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": false } }""")
|
||||
.Write.Enabled.ShouldBeFalse();
|
||||
|
||||
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000" }""")
|
||||
.Write.Enabled.ShouldBeFalse();
|
||||
|
||||
// The factory shares that parse, so an armed instance is authorable through it too.
|
||||
MTConnectDriverFactoryExtensions.CreateInstance(
|
||||
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
|
||||
.DriverInstanceId.ShouldBe("mt1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
|
||||
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
|
||||
/// the parse rather than silently defaulting to off. The <c>probe</c> leg is the control: it
|
||||
/// pins that this is the surrounding convention and not stricter behaviour invented here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Malformed_write_block_faults_exactly_like_the_sibling_probe_block()
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
|
||||
"mt1", """{ "agentUri": "http://x:5000", "probe": { "enabled": "yes" } }"""));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
|
||||
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": "yes" } }"""));
|
||||
|
||||
// A null block is not malformed — the same "omitted means default" reading the sibling
|
||||
// blocks get, since every DTO property is nullable.
|
||||
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000", "write": null }""")
|
||||
.Write.Enabled.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="MTConnectDriver"/>'s <see cref="IWritable"/> leg — the batch contract, the per-slot
|
||||
/// refusals, and the one thing the driver has to get right before the Agent is even dialled: which
|
||||
/// device a DataItem belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The write itself is the live gate's job.</b> Everything below stops at the
|
||||
/// <see cref="IMTConnectAgentClient"/> seam and asserts what the driver <i>asked</i> the Agent
|
||||
/// for (<see cref="CannedAgentClient.Writes"/>), never that a fake was called for its own sake —
|
||||
/// the same philosophy as <c>MqttDriverWriteTests</c>. What IS worth pinning offline is
|
||||
/// everything that decides whether a write should reach an Agent at all, because that is where
|
||||
/// a silent no-op would hide.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A write is observation injection, not machine command</b> (design §2.2): it sets what the
|
||||
/// Agent reports for a DataItem, and the next adapter update overwrites it. That is why the
|
||||
/// feature is off at two independent gates — the Agent's <c>AllowPut</c>, and this driver's
|
||||
/// <c>write.enabled</c> — and why the disabled leg below is the first test in the file.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectWriteTests
|
||||
{
|
||||
private const string AgentUri = "http://fixture-agent:5000";
|
||||
|
||||
/// <summary>The device the canned <c>Fixtures/probe.xml</c> declares — <c>id="dev1" name="VMC-3Axis"</c>.</summary>
|
||||
private const string FixtureDeviceName = "VMC-3Axis";
|
||||
|
||||
private const string PositionDataItemId = "dev1_pos";
|
||||
private const string ConditionDataItemId = "dev1_system_cond";
|
||||
|
||||
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
|
||||
private const string ConditionRawPath = "Plant/MTConnect/dev1/SystemCondition";
|
||||
private const string UnknownRawPath = "Plant/MTConnect/dev1/NotATag";
|
||||
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
private const uint BadNotWritable = 0x803B0000u;
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
private const uint BadNotConnected = 0x808A0000u;
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>One authored raw tag bound to a DataItem id, as the browse-commit flow writes it.</summary>
|
||||
private static RawTagEntry Entry(string rawPath, string dataItemId) =>
|
||||
new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1");
|
||||
|
||||
private static MTConnectDriverOptions Opts(
|
||||
bool writeEnabled = true, string? deviceName = null, params RawTagEntry[] rawTags) =>
|
||||
new()
|
||||
{
|
||||
AgentUri = AgentUri,
|
||||
DeviceName = deviceName,
|
||||
Tags = [],
|
||||
RawTags = rawTags,
|
||||
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
|
||||
};
|
||||
|
||||
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
|
||||
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
|
||||
return (new MTConnectDriver(options, "mt1", _ => client, logger), client);
|
||||
}
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedAsync(
|
||||
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
|
||||
{
|
||||
var (driver, client) = NewDriver(options, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>The ordinary rig: write on, one writable position tag and the fixture's CONDITION item.</summary>
|
||||
private static Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableDriverAsync(
|
||||
RecordingDriverLogger? logger = null) =>
|
||||
InitializedAsync(
|
||||
Opts(
|
||||
writeEnabled: true,
|
||||
deviceName: null,
|
||||
Entry(PositionRawPath, PositionDataItemId),
|
||||
Entry(ConditionRawPath, ConditionDataItemId)),
|
||||
logger);
|
||||
|
||||
// ---- the capability itself ----
|
||||
|
||||
[Fact]
|
||||
public void MTConnectDriver_implements_IWritable()
|
||||
// The interface's presence IS the feature: without it the runtime's capability dispatch never
|
||||
// offers this driver a write, whatever a node advertises.
|
||||
=> NewDriver(Opts()).Driver.ShouldBeAssignableTo<IWritable>();
|
||||
|
||||
// ---- the two independent gates ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_driver_with_write_disabled_refuses_every_slot_without_dialling_the_agent()
|
||||
{
|
||||
// The default. An existing deployment that never authors the block keeps today's read-only
|
||||
// behaviour EXACTLY — including making no request an Agent could log or accept.
|
||||
var (driver, client) = await InitializedAsync(
|
||||
Opts(writeEnabled: false, deviceName: null, Entry(PositionRawPath, PositionDataItemId)));
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.ShouldBe(BadNotWritable);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_before_initialize_reports_not_connected()
|
||||
{
|
||||
// Matches ReadAsync: "this driver is not running" is the only true statement available, and it
|
||||
// is NOT the same operator problem as an Agent that answered and refused.
|
||||
var (driver, _) = NewDriver(Opts(true, null, Entry(PositionRawPath, PositionDataItemId)));
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadNotConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_after_shutdown_reports_not_connected()
|
||||
{
|
||||
var (driver, _) = await WritableDriverAsync();
|
||||
await driver.ShutdownAsync(Ct);
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadNotConnected);
|
||||
}
|
||||
|
||||
// ---- per-slot refusals ----
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_reference_is_refused_in_its_own_slot()
|
||||
{
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(UnknownRawPath, 1.0)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_condition_data_item_is_refused_as_not_writable()
|
||||
{
|
||||
// CONDITION PUTs carry alarm semantics and a different level|text wire format — excluded from
|
||||
// v1. BadNotWritable, not BadTypeMismatch: the value was never the problem.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(ConditionRawPath, "NORMAL")], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadNotWritable);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_null_value_is_refused_as_a_type_mismatch()
|
||||
{
|
||||
// A write IS a URL query value; there is no null to send. Refusing beats writing the empty
|
||||
// string, which the Agent would accept as a real observation.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, null)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_array_value_is_refused_as_a_type_mismatch()
|
||||
{
|
||||
// Convert.ToString would happily render "System.Double[]" and the Agent would happily store it:
|
||||
// a silent corruption visible only on read-back. Array/TIME_SERIES writes are out of scope.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, new[] { 1.0, 2.0 })], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
|
||||
{
|
||||
// Convert.ToString runs the value's OWN ToString — arbitrary code that may throw anything —
|
||||
// and it runs OUTSIDE WriteAsync's per-slot try. A narrow catch filter let every exception
|
||||
// other than Format/InvalidCast/Overflow escape WriteAsync and fail the WHOLE batch, breaking
|
||||
// the never-throws contract. The load-bearing half of this test is the second slot: the good
|
||||
// write still happens.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest(PositionRawPath, new ThrowingFormatter()),
|
||||
new WriteRequest(PositionRawPath, 42.5),
|
||||
],
|
||||
Ct);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
results[1].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
|
||||
}
|
||||
|
||||
/// <summary>A value whose <see cref="ToString"/> throws something no conversion filter would name.</summary>
|
||||
private sealed class ThrowingFormatter
|
||||
{
|
||||
public override string ToString() =>
|
||||
throw new InvalidOperationException("this formatter is broken");
|
||||
}
|
||||
|
||||
// ---- what actually reaches the seam ----
|
||||
|
||||
[Fact]
|
||||
public async Task An_accepted_write_reports_good_and_names_the_owning_device_and_data_item()
|
||||
{
|
||||
// The device NAME is resolved from the /probe model, not from config: the Agent's PUT surface
|
||||
// is addressed per device, and a multi-device Agent must be addressed correctly with no extra
|
||||
// authoring. The RawPath is translated to the DataItem id, exactly as the read path does.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem()
|
||||
.ShouldBe((FixtureDeviceName, PositionDataItemId, "42.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_value_is_rendered_with_the_invariant_culture()
|
||||
{
|
||||
// A comma decimal separator would be accepted by the Agent and read back as a different number.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PositionRawPath, 1234.5678d)], Ct);
|
||||
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("1234.5678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_boolean_is_rendered_as_its_invariant_true_false_string()
|
||||
{
|
||||
// Boolean.ToString is culture-independent, so the invariant rendering is "True"/"False" — NOT
|
||||
// "1"/"0". Documented because it is the value an MTConnect consumer will see verbatim.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PositionRawPath, true)], Ct);
|
||||
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("True");
|
||||
}
|
||||
|
||||
// ---- device-name resolution ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_data_item_the_probe_does_not_declare_falls_back_to_the_configured_device()
|
||||
{
|
||||
// The dataItemId-keyed authoring surface (the driver CLI) and any tag whose DataItem the Agent
|
||||
// has since dropped: config is the only device name available, and it is a real one.
|
||||
var (driver, client) = await InitializedAsync(
|
||||
Opts(writeEnabled: true, deviceName: "ConfiguredDevice", Entry(PositionRawPath, "not_in_probe")));
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem().ShouldBe(("ConfiguredDevice", "not_in_probe", "7"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_data_item_with_no_resolvable_device_is_refused_as_an_unknown_node()
|
||||
{
|
||||
// Neither the probe model nor config names a device, so there is no URL to POST to. Refused
|
||||
// rather than guessed: a wrong device path is a 404 the operator reads as "the Agent is broken".
|
||||
var (driver, client) = await InitializedAsync(
|
||||
Opts(writeEnabled: true, deviceName: null, Entry(PositionRawPath, "not_in_probe")));
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- how the Agent's own answers map ----
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_refusal_maps_to_a_communication_error_and_logs_the_agents_own_text()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var (driver, client) = await WritableDriverAsync(logger);
|
||||
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
logger.WarningsSnapshot().ShouldContain(w => w.Contains("UNSUPPORTED: no such device", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_refusal_never_reaches_the_health_LastError()
|
||||
{
|
||||
// Type-only discipline: LastError carries an exception TYPE, never Agent-authored text, and a
|
||||
// refused write is not evidence the driver's read path is degraded either.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
var before = driver.GetHealth();
|
||||
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
var after = driver.GetHealth();
|
||||
after.LastError.ShouldBe(before.LastError);
|
||||
after.State.ShouldBe(before.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transport_failure_maps_to_a_communication_error_without_throwing()
|
||||
{
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
|
||||
|
||||
results[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
// ---- batch contract ----
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_fills_the_remaining_slots_and_returns_the_batch()
|
||||
{
|
||||
// Never throws, even cancelled: the caller gets one status per request or the node-manager
|
||||
// cannot tell which writes were attempted.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PositionRawPath, 1.0), new WriteRequest(PositionRawPath, 2.0)], cts.Token);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.ShouldAllBe(r => r.StatusCode == BadCommunicationError);
|
||||
client.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_mid_batch_leaves_the_slots_already_written_with_their_real_outcomes()
|
||||
{
|
||||
// The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against
|
||||
// the Agent before the token was cancelled, and reporting it BadCommunicationError would tell
|
||||
// an operator a write failed that actually landed — the one thing worse than reporting a
|
||||
// failure. Cancellation is injected from inside slot 1's own formatting, so the ordering is a
|
||||
// fact of the run rather than a timing hope.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest(PositionRawPath, 42.5),
|
||||
new WriteRequest(PositionRawPath, new CancellingFormatter(cts)),
|
||||
new WriteRequest(PositionRawPath, 7.5),
|
||||
],
|
||||
cts.Token);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(Good);
|
||||
results[1].StatusCode.ShouldBe(BadCommunicationError);
|
||||
results[2].StatusCode.ShouldBe(BadCommunicationError);
|
||||
|
||||
// Exactly the pre-cancellation slot reached the Agent.
|
||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
|
||||
}
|
||||
|
||||
/// <summary>Cancels the batch from inside its own rendering, then renders normally.</summary>
|
||||
private sealed class CancellingFormatter(CancellationTokenSource cts)
|
||||
{
|
||||
public override string ToString()
|
||||
{
|
||||
cts.Cancel();
|
||||
|
||||
return "99";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
|
||||
{
|
||||
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
|
||||
// write in the same OPC UA request.
|
||||
var (driver, client) = await WritableDriverAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest(UnknownRawPath, 1.0),
|
||||
new WriteRequest(ConditionRawPath, "NORMAL"),
|
||||
new WriteRequest(PositionRawPath, 42.5),
|
||||
],
|
||||
Ct);
|
||||
|
||||
// Order-preserving, one status per request, each naming its own distinct cause.
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
results[1].StatusCode.ShouldBe(BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(Good);
|
||||
client.Writes.ShouldHaveSingleItem().DataItemId.ShouldBe(PositionDataItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_batch_is_answered_with_an_empty_result()
|
||||
=> (await NewDriver(Opts()).Driver.WriteAsync([], Ct)).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_rejects_a_null_batch()
|
||||
=> await Should.ThrowAsync<ArgumentNullException>(
|
||||
async () => await NewDriver(Opts()).Driver.WriteAsync(null!, Ct));
|
||||
}
|
||||
@@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests
|
||||
await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
||||
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
|
||||
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
|
||||
/// names) — ODP.NET's shape, and the only place the two spellings differ.
|
||||
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
|
||||
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
|
||||
/// recorded names would carry the prefix.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
await using var session = new SqlBrowseSession(db.Connection, dialect);
|
||||
|
||||
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);
|
||||
dialect.BoundParameterNames.ShouldContain("schema");
|
||||
dialect.BoundParameterNames.ShouldContain("table");
|
||||
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
|
||||
dialect.ConfiguredCommands.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests
|
||||
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
|
||||
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
|
||||
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
|
||||
/// member fail here rather than silently browse as SQL Server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultDialectSelector_carriesSqlServerOnly()
|
||||
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
|
||||
{
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
|
||||
|
||||
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
|
||||
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
|
||||
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
|
||||
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
// ---- provider availability ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
|
||||
/// reserved and must be reported as unavailable, naming itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
|
||||
{
|
||||
@@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("Postgres");
|
||||
ex.Message.ShouldContain("MySql");
|
||||
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.
|
||||
/// MySql" is true whatever the ref resolves to, and the two failures must not mask each other.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
|
||||
@@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A carried provider must get <b>past</b> the provider gate and on to the environment, so that a
|
||||
/// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability.
|
||||
/// Asserted through the reads list: reaching the environment reader is the observable proof the gate
|
||||
/// let the provider through.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Postgres")]
|
||||
[InlineData("Oracle")]
|
||||
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
|
||||
{
|
||||
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(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldNotContain("not available in this build");
|
||||
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
|
||||
}
|
||||
|
||||
// ---- malformed configuration ----
|
||||
|
||||
[Theory]
|
||||
|
||||
+7
@@ -45,6 +45,13 @@
|
||||
— see its class docs.
|
||||
-->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
|
||||
<!--
|
||||
Linked for the same reason and on the same terms: ColonMarkerDialect is the control for
|
||||
dialect-owned parameter naming (":schema" in the text, bare "schema" on the parameter — ODP.NET's
|
||||
shape), and the browse session binds those names. It decorates SqliteDialect, which is already
|
||||
linked above, so the pair travels together.
|
||||
-->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\ColonMarkerDialect.cs" Link="ColonMarkerDialect.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Dedicated, DISPOSABLE Oracle Database Free for the Sql driver's Oracle live gate
|
||||
# (OracleReadTests).
|
||||
#
|
||||
# ── SAFETY ─────────────────────────────────────────────────────────────────────
|
||||
# This stack owns its OWN container, `otopcua-sql-oracle`, on host port 11521 — deliberately not
|
||||
# the default 1521, so it can never be confused with a real Oracle listener on the rig. `restart:
|
||||
# "no"` so a killed container stays down rather than respawning.
|
||||
#
|
||||
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
|
||||
# # from a Windows VM:
|
||||
# lmxopcua-fix sync sql-oracle # rsync this dir → /opt/otopcua-sql-oracle/
|
||||
# lmxopcua-fix up sql-oracle # single-service stack (+ one-shot seed), no profile arg
|
||||
#
|
||||
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
|
||||
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/ \
|
||||
# dohertj2@10.100.0.35:/opt/otopcua-sql-oracle/
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-oracle && SQL_ORACLE_PASSWORD=... docker compose up -d'
|
||||
#
|
||||
# ⚠️ FIRST START TAKES MINUTES, not seconds — the image creates the database on first boot. The
|
||||
# healthcheck's start_period reflects that; the seed one-shot simply waits for it.
|
||||
#
|
||||
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
|
||||
# export SQL_ORACLE_ENDPOINT=10.100.0.35:11521
|
||||
# export SQL_ORACLE_USER=otopcua # optional; this is the default
|
||||
# export SQL_ORACLE_PASSWORD=... # the same value this compose was brought up with
|
||||
# export SQL_ORACLE_SERVICE=FREEPDB1 # optional; this is the default (the image's PDB)
|
||||
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
|
||||
# --filter "FullyQualifiedName~OracleReadTests"
|
||||
#
|
||||
# The one-shot `oracle-seed` service applies seed-oracle.sql once the server is healthy. The
|
||||
# gvenzl images also auto-run scripts mounted at /container-entrypoint-initdb.d, but that path
|
||||
# runs ONLY on the very first boot of an empty data volume — a re-seed after editing the script
|
||||
# would silently not happen. The one-shot service matches the mssql fixture and re-runs every
|
||||
# `docker compose up`, which is why it is used here.
|
||||
|
||||
name: otopcua-sql-oracle
|
||||
|
||||
services:
|
||||
oracle:
|
||||
image: gvenzl/oracle-free:23-slim
|
||||
container_name: otopcua-sql-oracle
|
||||
labels:
|
||||
project: lmxopcua
|
||||
restart: "no"
|
||||
environment:
|
||||
# SYS/SYSTEM password. Dev-only sandbox value for a throwaway container.
|
||||
ORACLE_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
|
||||
# The image creates this non-SYS user (and its schema) inside the FREEPDB1 pluggable
|
||||
# database. It is the principal the suite connects as — nothing here ever uses SYS.
|
||||
APP_USER: otopcua
|
||||
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
|
||||
ports:
|
||||
- "11521:1521"
|
||||
healthcheck:
|
||||
# healthcheck.sh ships in the image and reports the database OPEN, not merely the listener up.
|
||||
test: ["CMD", "healthcheck.sh"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 40
|
||||
start_period: 180s
|
||||
|
||||
# One-shot seed: waits for the database to pass its healthcheck, applies seed-oracle.sql, exits.
|
||||
# Reuses the server image purely because it already carries sqlplus.
|
||||
oracle-seed:
|
||||
image: gvenzl/oracle-free:23-slim
|
||||
labels:
|
||||
project: lmxopcua
|
||||
restart: "no"
|
||||
depends_on:
|
||||
oracle:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
|
||||
volumes:
|
||||
- ./seed-oracle.sql:/seed.sql:ro
|
||||
entrypoint:
|
||||
- "/bin/bash"
|
||||
- "-c"
|
||||
# -S silent, -L single login attempt (never prompt). The script's WHENEVER SQLERROR EXIT
|
||||
# makes a broken seed fail this service rather than leave a half-seeded schema behind.
|
||||
- "sqlplus -S -L otopcua/\"$${APP_USER_PASSWORD}\"@//oracle:1521/FREEPDB1 @/seed.sql"
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
-- Seed for the Sql driver's Oracle live gate (OracleReadTests), applied once by the compose
|
||||
-- `oracle-seed` one-shot AS THE APPLICATION USER (otopcua) against the OWNED `otopcua-sql-oracle`
|
||||
-- container. Oracle equates a schema with a user, so everything below lands in the OTOPCUA schema.
|
||||
--
|
||||
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
|
||||
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
|
||||
-- its first — so the same assertions can be made against both backends and a divergence between
|
||||
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
|
||||
-- test different things.
|
||||
--
|
||||
-- ⚠️ EVERY IDENTIFIER HERE IS UNQUOTED, AND THAT IS THE POINT. Oracle upper-cases an unquoted
|
||||
-- identifier, so the catalog stores OTOPCUA / TAG_VALUES / TAG_NAME while an operator authoring a
|
||||
-- tag in the AdminUI naturally types lower case. The suite's case-folding leg authors the lower-case
|
||||
-- spelling and expects `SqlCatalogGate` to resolve it through `SqlCatalog.TryMatch` (exact-ordinal
|
||||
-- first, then a UNIQUE case-insensitive match) and substitute the catalog's own UPPER-case spelling
|
||||
-- before `OracleDialect.QuoteIdentifier` double-quotes it — which is load-bearing, because a quoted
|
||||
-- "tag_values" and a quoted "TAG_VALUES" are DIFFERENT objects to Oracle. Quoting a name here would
|
||||
-- make that leg meaningless.
|
||||
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||
SET FEEDBACK OFF
|
||||
SET DEFINE OFF
|
||||
|
||||
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
|
||||
-- row from an older revision of this file would make a passing test meaningless. ORA-00942 (table
|
||||
-- does not exist) is the expected outcome on a first run and is the only error swallowed.
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP TABLE tag_values PURGE';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'DROP TABLE latest_status PURGE';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
|
||||
-- num_value is NUMBER, Oracle's one numeric family — which OracleDialect.MapColumnType folds to
|
||||
-- Float64 (the documented under-specification; see that method's remarks).
|
||||
CREATE TABLE tag_values (
|
||||
tag_name VARCHAR2(128) NOT NULL,
|
||||
num_value NUMBER,
|
||||
sample_ts TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO tag_values (tag_name, num_value, sample_ts)
|
||||
VALUES ('Line1.Speed', 42, TIMESTAMP '2026-07-24 10:00:00');
|
||||
INSERT INTO tag_values (tag_name, num_value, sample_ts)
|
||||
VALUES ('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01');
|
||||
INSERT INTO tag_values (tag_name, num_value, sample_ts)
|
||||
VALUES ('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
|
||||
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
|
||||
-- ORDER BY … DESC (or its FETCH FIRST 1 ROWS ONLY) returns a different, wrong value rather than a
|
||||
-- coincidentally right one.
|
||||
CREATE TABLE latest_status (
|
||||
station_id NUMBER(10) NOT NULL,
|
||||
oven_temp NUMBER,
|
||||
pressure NUMBER,
|
||||
sample_ts TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
|
||||
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00');
|
||||
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
|
||||
VALUES (9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01');
|
||||
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
|
||||
VALUES (8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
COMMIT;
|
||||
EXIT;
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Dedicated, DISPOSABLE PostgreSQL for the Sql driver's Postgres live gate (PostgresReadTests).
|
||||
#
|
||||
# ── SAFETY ─────────────────────────────────────────────────────────────────────
|
||||
# This stack owns its OWN postgres container, `otopcua-sql-pg`, on host port 15432.
|
||||
# Nothing else on the rig runs PostgreSQL, so there is no shared-server hazard of the
|
||||
# kind the mssql blackhole fixture guards against — but the same discipline applies:
|
||||
# a distinct container_name and a distinct host port, and `restart: "no"` so a killed
|
||||
# container stays down rather than respawning.
|
||||
#
|
||||
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
|
||||
# # from a Windows VM:
|
||||
# lmxopcua-fix sync sql-pg # rsync this dir → /opt/otopcua-sql-pg/
|
||||
# lmxopcua-fix up sql-pg # single-service stack (+ one-shot seed), no profile arg
|
||||
#
|
||||
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
|
||||
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/ \
|
||||
# dohertj2@10.100.0.35:/opt/otopcua-sql-pg/
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-pg && SQL_PG_PASSWORD=... docker compose up -d'
|
||||
#
|
||||
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
|
||||
# export SQL_PG_ENDPOINT=10.100.0.35:15432
|
||||
# export SQL_PG_USER=otopcua # optional; this is the default
|
||||
# export SQL_PG_PASSWORD=... # the same value this compose was brought up with
|
||||
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
|
||||
# --filter "FullyQualifiedName~PostgresReadTests"
|
||||
#
|
||||
# The one-shot `postgres-seed` service applies seed-pg.sql once the server is healthy, so
|
||||
# the stack is self-seeding. Unlike the mssql fixture the suite does NOT seed defensively
|
||||
# on connect: the seed creates ROLES (an owner role that is not the connecting principal),
|
||||
# which the read-only fixture login deliberately cannot do — see seed-pg.sql.
|
||||
|
||||
name: otopcua-sql-pg
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: otopcua-sql-pg
|
||||
labels:
|
||||
project: lmxopcua
|
||||
restart: "no"
|
||||
environment:
|
||||
# Dev-only sandbox password for a throwaway container; the seed gives the unprivileged
|
||||
# `otopcua` login the SAME value, which is what the suite reads from SQL_PG_PASSWORD.
|
||||
POSTGRES_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
|
||||
POSTGRES_DB: sqlpollfixture
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
# pg_isready against the fixture database: the server accepting a connection to the
|
||||
# database the seed targets is the readiness signal, not merely a listening socket.
|
||||
test:
|
||||
- "CMD-SHELL"
|
||||
- "pg_isready -U postgres -d sqlpollfixture || exit 1"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
# One-shot seed: waits for the server to pass its healthcheck, applies seed-pg.sql, exits.
|
||||
postgres-seed:
|
||||
image: postgres:17-alpine
|
||||
labels:
|
||||
project: lmxopcua
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
|
||||
APP_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
|
||||
volumes:
|
||||
- ./seed-pg.sql:/seed.sql:ro
|
||||
entrypoint:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
# ON_ERROR_STOP=1 so a broken seed fails the service rather than leaving a half-seeded
|
||||
# database that makes a later test failure look like a driver bug.
|
||||
- "psql -v ON_ERROR_STOP=1 -v app_password=\"$${APP_PASSWORD}\" -h postgres -U postgres -d sqlpollfixture -f /seed.sql"
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
-- Seed for the Sql driver's PostgreSQL live gate (PostgresReadTests), applied once by the
|
||||
-- compose `postgres-seed` one-shot as the superuser against the OWNED `otopcua-sql-pg` container.
|
||||
--
|
||||
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
|
||||
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
|
||||
-- its first — so the same assertions can be made against both backends and a divergence between
|
||||
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
|
||||
-- test different things.
|
||||
--
|
||||
-- ── TWO THINGS HERE ARE LOAD-BEARING, NOT SCAFFOLDING ─────────────────────────────────────────
|
||||
--
|
||||
-- 1. The schema is owned by `sqlpoll_owner` and the suite connects as `otopcua`, which holds
|
||||
-- USAGE + SELECT and nothing else. That is the exact provisioning `PostgresDialect.ListSchemasSql`
|
||||
-- was written for: `information_schema.schemata` lists only schemas the current role OWNS, so a
|
||||
-- granted-not-owning read-only poller would see an empty schema list, `SqlCatalogLoader` would
|
||||
-- throw its "the catalog returned no schemas at all" grant diagnosis, and every tag would be
|
||||
-- rejected. Sourcing the schema list from `information_schema.tables` instead is what makes this
|
||||
-- work — and a fixture where the poller owned its own schema would pass either way, proving
|
||||
-- nothing. Do not "simplify" this by creating the tables as `otopcua`.
|
||||
--
|
||||
-- 2. Every identifier is created UNQUOTED, so Postgres folds it to lower case in the catalog. The
|
||||
-- suite's case-folding leg authors the same names in UPPER case and expects them to resolve
|
||||
-- through `SqlCatalog.TryMatch` (exact-ordinal first, then a UNIQUE case-insensitive match, which
|
||||
-- substitutes the catalog's own spelling). Quoting a name here would make the catalog spelling
|
||||
-- mixed case and that leg would stop meaning anything.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- ── roles ─────────────────────────────────────────────────────────────────────────────────────
|
||||
-- \gexec runs the row the SELECT produced, and produces no row when the role already exists —
|
||||
-- the idempotent CREATE ROLE idiom. A DO block cannot be used for the login role: psql does not
|
||||
-- interpolate :'app_password' inside a dollar-quoted body.
|
||||
SELECT 'CREATE ROLE sqlpoll_owner NOLOGIN'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sqlpoll_owner')
|
||||
\gexec
|
||||
|
||||
SELECT 'CREATE ROLE otopcua LOGIN'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'otopcua')
|
||||
\gexec
|
||||
|
||||
-- Set (or re-set) the password every run, so re-seeding with a different SQL_PG_PASSWORD converges
|
||||
-- rather than leaving the suite unable to log in.
|
||||
ALTER ROLE otopcua LOGIN PASSWORD :'app_password';
|
||||
|
||||
-- ── schema ────────────────────────────────────────────────────────────────────────────────────
|
||||
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
|
||||
-- row from an older revision of this file would make a passing test meaningless. The blast radius
|
||||
-- is bounded to this fixture-owned schema.
|
||||
DROP SCHEMA IF EXISTS sqlpoll CASCADE;
|
||||
CREATE SCHEMA sqlpoll AUTHORIZATION sqlpoll_owner;
|
||||
|
||||
-- Everything below is created AS THE OWNER, not as the superuser — see note 1 above.
|
||||
SET ROLE sqlpoll_owner;
|
||||
|
||||
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
|
||||
CREATE TABLE sqlpoll.tag_values (
|
||||
tag_name varchar(128) NOT NULL,
|
||||
num_value double precision NULL,
|
||||
sample_ts timestamp(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sqlpoll.tag_values (tag_name, num_value, sample_ts)
|
||||
VALUES ('Line1.Speed', 42.0, TIMESTAMP '2026-07-24 10:00:00'),
|
||||
('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01'),
|
||||
('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
|
||||
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
|
||||
-- ORDER BY … DESC (or its LIMIT 1) returns a different, wrong value rather than a coincidentally
|
||||
-- right one.
|
||||
CREATE TABLE sqlpoll.latest_status (
|
||||
station_id integer NOT NULL,
|
||||
oven_temp double precision NULL,
|
||||
pressure double precision NULL,
|
||||
sample_ts timestamp(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sqlpoll.latest_status (station_id, oven_temp, pressure, sample_ts)
|
||||
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
|
||||
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
|
||||
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
|
||||
GRANT USAGE ON SCHEMA sqlpoll TO otopcua;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA sqlpoll TO otopcua;
|
||||
@@ -0,0 +1,545 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
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.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver against a <b>real Oracle Database</b>, over the fully managed ODP.NET provider
|
||||
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
|
||||
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
|
||||
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
|
||||
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
|
||||
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
|
||||
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
|
||||
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
|
||||
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
|
||||
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
|
||||
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
|
||||
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
|
||||
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
|
||||
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
|
||||
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
|
||||
/// per provider.</para>
|
||||
/// </summary>
|
||||
[Collection(OracleCollection.Name)]
|
||||
public sealed class OracleReadTests
|
||||
{
|
||||
private readonly OracleFixture _ora;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
|
||||
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
|
||||
public OracleReadTests(OracleFixture ora) => _ora = ora;
|
||||
|
||||
// ---- lifecycle: liveness + catalog load ----
|
||||
|
||||
/// <summary>
|
||||
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
|
||||
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
|
||||
/// described credential-free.
|
||||
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
|
||||
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
|
||||
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
|
||||
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
|
||||
/// loader expects.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.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. Oracle's connection string
|
||||
// names the database inside Data Source (host:port/service), so the service is what must survive —
|
||||
// and the credential must not.
|
||||
driver.Endpoint.ShouldContain(_ora.ServiceName);
|
||||
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
|
||||
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>
|
||||
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
|
||||
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
|
||||
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
|
||||
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
|
||||
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
|
||||
/// explicit authored <c>"type"</c>.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.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, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
|
||||
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.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.
|
||||
/// <para><b>This is the live falsification of positional binding.</b> With
|
||||
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
|
||||
/// parameter collection regardless of their names, and the failure mode is not an exception but a
|
||||
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
|
||||
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(
|
||||
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
|
||||
KeyValue("Sql/Line1/Pressure", OracleFixture.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, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.PresentValue);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.SecondPresentValue);
|
||||
}
|
||||
|
||||
// ---- wide-row model: the Oracle-specific row-limit syntax ----
|
||||
|
||||
/// <summary>
|
||||
/// The <c>topByTimestamp</c> selector resolves to the newest row.
|
||||
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
|
||||
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
|
||||
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
|
||||
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
|
||||
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
|
||||
/// different, wrong value here rather than a coincidentally-right one.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.NewestStationOvenTemp);
|
||||
}
|
||||
|
||||
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
|
||||
|
||||
/// <summary>
|
||||
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
|
||||
/// <c>OTOPCUA.TAG_VALUES</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
|
||||
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
|
||||
/// anything is quoted into SQL.
|
||||
/// <para><b>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
|
||||
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
|
||||
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
|
||||
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
|
||||
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
|
||||
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
|
||||
/// identifiers by hand.</para>
|
||||
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
|
||||
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
|
||||
/// pinned offline in the unit suites.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var lower = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
|
||||
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
|
||||
["keyValue"] = OracleFixture.PresentKey,
|
||||
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(lower);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- browse session: the AdminUI's schema → table → column walk ----
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
|
||||
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
|
||||
/// over a real database, and the leaf's mapped driver type is the one
|
||||
/// <c>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
|
||||
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
|
||||
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
|
||||
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
|
||||
/// binding. Reaching a non-empty table list here is therefore evidence that
|
||||
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
|
||||
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
|
||||
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
|
||||
/// seeded schema is one of many.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var config = new JsonObject
|
||||
{
|
||||
["provider"] = nameof(SqlProvider.Oracle),
|
||||
["connectionString"] = _ora.ConnectionString,
|
||||
}.ToJsonString();
|
||||
|
||||
await using var session = await new SqlDriverBrowser()
|
||||
.OpenAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
|
||||
// return, which is the only useful diagnostic when a browse walk comes back short.
|
||||
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
|
||||
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
|
||||
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
|
||||
|
||||
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
|
||||
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
|
||||
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
|
||||
|
||||
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
|
||||
columns.Select(c => c.DisplayName).ShouldBe(
|
||||
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.TimestampColumn]);
|
||||
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
|
||||
|
||||
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
|
||||
// column, or the commit falls through to a generic blob the typed editor cannot read.
|
||||
var value = columns.Single(c => c.DisplayName == OracleFixture.ValueColumn);
|
||||
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
|
||||
|
||||
var address = attribute.AddressFields.ShouldNotBeNull();
|
||||
address["schema"].ShouldBe(OracleFixture.Schema);
|
||||
address["table"].ShouldBe(OracleFixture.KeyValueTable);
|
||||
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
|
||||
/// precisely what makes this suite exercise <c>OracleClientFactory.Instance</c>, the provider the
|
||||
/// dialect ships.
|
||||
/// </summary>
|
||||
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
|
||||
{
|
||||
var options = new SqlDriverOptions
|
||||
{
|
||||
RawTags = tags,
|
||||
CommandTimeout = TimeSpan.FromSeconds(15),
|
||||
OperationTimeout = TimeSpan.FromSeconds(20),
|
||||
};
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId: "sql-oracle-integration",
|
||||
dialect: new OracleDialect(),
|
||||
connectionString: _ora.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, authored in the catalog's own spelling.</summary>
|
||||
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
|
||||
["keyColumn"] = OracleFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = OracleFixture.ValueColumn,
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
/// <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"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
|
||||
["columnName"] = column,
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn,
|
||||
["rowSelector"] = new JsonObject
|
||||
{
|
||||
["topByTimestamp"] = OracleFixture.TimestampColumn,
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
|
||||
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
|
||||
/// <see cref="SqlServerReadTests"/> gives.
|
||||
/// </summary>
|
||||
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
|
||||
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
|
||||
/// the Oracle counterpart of <see cref="SqlPollServerFixture"/>.
|
||||
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
|
||||
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent, it
|
||||
/// records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>.</para>
|
||||
/// <para><b>This fixture does not seed — the compose stack does</b>
|
||||
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
|
||||
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
|
||||
/// land in the <c>OTOPCUA</c> schema.</para>
|
||||
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
|
||||
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
|
||||
/// place that deliberately authors the lower-case form an operator would actually type.</para>
|
||||
/// </summary>
|
||||
public sealed class OracleFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
|
||||
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
|
||||
|
||||
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
|
||||
public const string UserEnvVar = "SQL_ORACLE_USER";
|
||||
|
||||
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
|
||||
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
|
||||
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
|
||||
/// </summary>
|
||||
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
|
||||
|
||||
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
|
||||
public const string DefaultUser = "otopcua";
|
||||
|
||||
/// <summary>The image's pluggable-database service name.</summary>
|
||||
public const string DefaultService = "FREEPDB1";
|
||||
|
||||
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
|
||||
public const int DefaultPort = 1521;
|
||||
|
||||
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
|
||||
public const string Schema = "OTOPCUA";
|
||||
|
||||
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
|
||||
private const int ProbeConnectTimeoutSeconds = 10;
|
||||
|
||||
// ---- key-value (EAV) source ----
|
||||
|
||||
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
|
||||
public const string KeyValueTable = "TAG_VALUES";
|
||||
|
||||
/// <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"/> — different from
|
||||
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
|
||||
public const double SecondPresentValue = 3.5;
|
||||
|
||||
/// <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 NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
|
||||
public const string WideRowTable = "LATEST_STATUS";
|
||||
|
||||
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
|
||||
public const string OvenTempColumn = "OVEN_TEMP";
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="OvenTempColumn"/> of the row holding the <b>newest</b> <c>SAMPLE_TS</c> — what a
|
||||
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
|
||||
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its
|
||||
/// <c>FETCH FIRST 1 ROWS ONLY</c> picks a different value and the test says so.
|
||||
/// </summary>
|
||||
public const double NewestStationOvenTemp = 210.25;
|
||||
|
||||
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
|
||||
public string ConnectionString { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>The resolved service name, for the endpoint-description assertion.</summary>
|
||||
public string ServiceName { get; private set; } = DefaultService;
|
||||
|
||||
/// <summary>The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
|
||||
public string Server { get; private set; } = string.Empty;
|
||||
|
||||
/// <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>
|
||||
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
|
||||
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or seeded
|
||||
/// here (the compose stack owns that).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (ResolveConnectionString() is not { } resolved)
|
||||
{
|
||||
SkipReason =
|
||||
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
|
||||
$"default '{DefaultUser}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
|
||||
"Sql driver's live Oracle suite. Bring the fixture up from " +
|
||||
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionString = resolved;
|
||||
|
||||
var dialect = new OracleDialect();
|
||||
try
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException("ODP.NET returned no connection object.");
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = ConnectionString;
|
||||
await connection.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
|
||||
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
|
||||
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
|
||||
"stack is up (its first boot takes minutes), or leave them unset to skip.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Reads the gate and composes the connection string <b>through the dialect's own provider
|
||||
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via the
|
||||
/// abstract <see cref="DbConnectionStringBuilder"/> indexer.
|
||||
/// <para>That indirection is deliberate: it keeps this project free of a direct ODP.NET
|
||||
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
|
||||
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
|
||||
/// <c>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
|
||||
/// be accepted through that indexer, so no provider-specific property is needed.</para>
|
||||
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
|
||||
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
|
||||
/// install and no Oracle configuration files anywhere near the test host.</para>
|
||||
/// </summary>
|
||||
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
|
||||
private string? ResolveConnectionString()
|
||||
{
|
||||
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, and Oracle locks an account after a handful of those.
|
||||
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
|
||||
|
||||
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
|
||||
? named
|
||||
: DefaultUser;
|
||||
|
||||
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
|
||||
? service
|
||||
: DefaultService;
|
||||
|
||||
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
|
||||
Server = $"{host}:{port}";
|
||||
|
||||
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
|
||||
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
|
||||
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
|
||||
builder["User Id"] = user;
|
||||
builder["Password"] = password;
|
||||
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the gate is resolved once per test session rather than once per test
|
||||
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class OracleCollection : ICollectionFixture<OracleFixture>
|
||||
{
|
||||
/// <summary>The collection name test classes reference.</summary>
|
||||
public const string Name = "SqlOracleServer";
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
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.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
|
||||
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
|
||||
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
|
||||
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
|
||||
/// catalog SQL — including the deliberate choice to derive the schema list from
|
||||
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
|
||||
/// granted-but-not-owning principal can prove.
|
||||
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> 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>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
|
||||
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
|
||||
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
|
||||
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
|
||||
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
|
||||
/// walk an operator authors through.</para>
|
||||
/// </summary>
|
||||
[Collection(PostgresCollection.Name)]
|
||||
public sealed class PostgresReadTests
|
||||
{
|
||||
private readonly PostgresFixture _pg;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
|
||||
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
|
||||
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
|
||||
|
||||
// ---- lifecycle: liveness + catalog load ----
|
||||
|
||||
/// <summary>
|
||||
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
|
||||
/// reports Healthy with the endpoint described credential-free.
|
||||
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
|
||||
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
|
||||
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
|
||||
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
|
||||
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
|
||||
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
|
||||
/// would have answered empty for.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.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 credential that reached the provider.
|
||||
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
|
||||
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
|
||||
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.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, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
||||
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
|
||||
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
|
||||
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
|
||||
}
|
||||
|
||||
// ---- wide-row model: the Postgres-specific row-limit syntax ----
|
||||
|
||||
/// <summary>
|
||||
/// The <c>topByTimestamp</c> selector resolves to the newest row.
|
||||
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
|
||||
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
|
||||
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
|
||||
/// first-inserted nor the lowest-keyed one, so losing either the limit 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_realPostgres_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, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
|
||||
}
|
||||
|
||||
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
|
||||
|
||||
/// <summary>
|
||||
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
|
||||
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
|
||||
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
|
||||
/// anything is quoted into SQL.
|
||||
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
|
||||
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
|
||||
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
|
||||
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
|
||||
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
|
||||
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
|
||||
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
|
||||
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var upper = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
|
||||
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
|
||||
["keyValue"] = PostgresFixture.PresentKey,
|
||||
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
|
||||
["timestampColumn"] = PostgresFixture.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();
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- browse session: the AdminUI's schema → table → column walk ----
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
|
||||
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
|
||||
/// over a real database, and the leaf's mapped driver type is the one
|
||||
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
|
||||
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
|
||||
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
|
||||
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
|
||||
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
|
||||
/// browse session reads every catalog column by name, so an unquoted alias would fold to
|
||||
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
|
||||
/// list.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var config = new JsonObject
|
||||
{
|
||||
["provider"] = nameof(SqlProvider.Postgres),
|
||||
["connectionString"] = _pg.ConnectionString,
|
||||
}.ToJsonString();
|
||||
|
||||
await using var session = await new SqlDriverBrowser()
|
||||
.OpenAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
|
||||
var schema = schemas.ShouldHaveSingleItem(
|
||||
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
|
||||
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
|
||||
|
||||
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
|
||||
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
|
||||
// return, which is the only useful diagnostic when a browse walk comes back short.
|
||||
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
|
||||
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
|
||||
|
||||
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
|
||||
columns.Select(c => c.DisplayName).ShouldBe(
|
||||
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
|
||||
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
|
||||
|
||||
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
|
||||
// column, or the commit falls through to a generic blob the typed editor cannot read.
|
||||
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
|
||||
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
|
||||
|
||||
var address = attribute.AddressFields.ShouldNotBeNull();
|
||||
address["schema"].ShouldBe(PostgresFixture.Schema);
|
||||
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
|
||||
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
|
||||
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
|
||||
/// ships.
|
||||
/// </summary>
|
||||
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
|
||||
{
|
||||
var options = new SqlDriverOptions
|
||||
{
|
||||
RawTags = tags,
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId: "sql-postgres-integration",
|
||||
dialect: new PostgresDialect(),
|
||||
connectionString: _pg.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"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
|
||||
["keyColumn"] = PostgresFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = PostgresFixture.ValueColumn,
|
||||
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
/// <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"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
|
||||
["columnName"] = column,
|
||||
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
||||
["rowSelector"] = new JsonObject
|
||||
{
|
||||
["topByTimestamp"] = PostgresFixture.TimestampColumn,
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
|
||||
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
|
||||
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
|
||||
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
|
||||
/// </summary>
|
||||
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
|
||||
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
|
||||
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
|
||||
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
|
||||
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both 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.</para>
|
||||
/// <para><b>This fixture does not seed — the compose stack does</b>
|
||||
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
|
||||
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
|
||||
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
|
||||
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
|
||||
/// superuser, and the granted-not-owning provisioning that
|
||||
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
|
||||
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
|
||||
|
||||
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
|
||||
public const string UserEnvVar = "SQL_PG_USER";
|
||||
|
||||
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
|
||||
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
|
||||
|
||||
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
|
||||
public const string DefaultUser = "otopcua";
|
||||
|
||||
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
|
||||
public const int DefaultPort = 5432;
|
||||
|
||||
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
|
||||
public const string FixtureDatabase = "sqlpollfixture";
|
||||
|
||||
/// <summary>
|
||||
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
|
||||
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
|
||||
/// seed's drop-and-recreate is bounded to objects this fixture owns.
|
||||
/// </summary>
|
||||
public const string Schema = "sqlpoll";
|
||||
|
||||
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
|
||||
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
|
||||
|
||||
/// <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 varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
||||
public const string KeyValueTable = "tag_values";
|
||||
|
||||
/// <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>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 integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
||||
public const string WideRowTable = "latest_status";
|
||||
|
||||
/// <summary>
|
||||
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
|
||||
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
|
||||
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
|
||||
/// different value and the test says so.
|
||||
/// </summary>
|
||||
public const double NewestStationOvenTemp = 210.25;
|
||||
|
||||
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>The connection string to hand the code under test. 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>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>
|
||||
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
|
||||
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
|
||||
/// seeded here (the compose stack owns that).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (ResolveConnectionString() is not { } resolved)
|
||||
{
|
||||
SkipReason =
|
||||
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
|
||||
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
|
||||
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
|
||||
"without these.";
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionString = resolved;
|
||||
|
||||
var dialect = new PostgresDialect();
|
||||
try
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException("Npgsql returned no connection object.");
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = ConnectionString;
|
||||
await connection.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
|
||||
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
|
||||
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Reads the gate and composes the connection string <b>through the dialect's own provider
|
||||
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
|
||||
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
|
||||
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
|
||||
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
|
||||
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
|
||||
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
|
||||
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
|
||||
/// provider-specific property is needed.</para>
|
||||
/// </summary>
|
||||
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
|
||||
private string? ResolveConnectionString()
|
||||
{
|
||||
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, which is how accounts get locked out on a server that is not a throwaway.
|
||||
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
|
||||
|
||||
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
|
||||
? named
|
||||
: DefaultUser;
|
||||
|
||||
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
|
||||
Server = $"{host}:{port}";
|
||||
|
||||
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
|
||||
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
|
||||
builder["Host"] = host;
|
||||
builder["Port"] = port;
|
||||
builder["Database"] = FixtureDatabase;
|
||||
builder["Username"] = user;
|
||||
builder["Password"] = password;
|
||||
builder["Timeout"] = ProbeConnectTimeoutSeconds;
|
||||
builder["Application Name"] = DriverApplicationName;
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
|
||||
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The authored endpoint.</param>
|
||||
/// <param name="defaultPort">The port to use when none is carried.</param>
|
||||
/// <returns>The host and port.</returns>
|
||||
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
|
||||
{
|
||||
var separator = endpoint.LastIndexOf(':');
|
||||
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
|
||||
|
||||
var host = endpoint[..separator];
|
||||
return int.TryParse(
|
||||
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
|
||||
? (host, port)
|
||||
: (endpoint, defaultPort);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the gate is resolved once per test session rather than once per test
|
||||
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
|
||||
{
|
||||
/// <summary>The collection name test classes reference.</summary>
|
||||
public const string Name = "SqlPostgresServer";
|
||||
}
|
||||
+11
-3
@@ -27,12 +27,20 @@
|
||||
</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.
|
||||
No Microsoft.Data.SqlClient / Npgsql / Oracle.ManagedDataAccess.Core PackageReference here on purpose:
|
||||
this suite must exercise EXACTLY the providers the driver ships, and they arrive transitively through
|
||||
Driver.Sql. Adding one here would let the two versions drift and the suite would then prove something
|
||||
about a provider nobody deploys. The Postgres and Oracle suites therefore never name a provider type:
|
||||
they compose their connection strings through `dialect.Factory.CreateConnectionStringBuilder()` and the
|
||||
abstract DbConnectionStringBuilder indexer.
|
||||
|
||||
Driver.Sql.Browser is referenced for the browse-session walk (schema → table → column) the Postgres and
|
||||
Oracle suites drive through the AdminUI's real seam, SqlDriverBrowser — the only place the dialects'
|
||||
catalog SQL is exercised as the picker actually calls it.
|
||||
-->
|
||||
<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.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A <b>test-only</b> dialect that spells parameters the ODP.NET way — <c>:name</c> in the SQL text and a
|
||||
/// <b>bare</b> <c>name</c> as the <see cref="DbParameter.ParameterName"/> — over the SQLite fixture. It is
|
||||
/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded
|
||||
/// <c>"@"</c> before, and a planner or loader that still hardcodes it emits <c>@k0</c> against this
|
||||
/// dialect and fails loudly.
|
||||
/// <para><b>Why the two halves must differ.</b> A dialect whose marker and bind name are the same string
|
||||
/// (SQL Server, Npgsql, SQLite) cannot tell a call site that <em>copies</em> the plan's bind name apart
|
||||
/// from one that reuses the marker. Oracle is the provider where they genuinely diverge, so this fake
|
||||
/// reproduces that divergence rather than merely changing the prefix.</para>
|
||||
/// <para><b>It really executes.</b> Everything but the parameter naming (and the catalog SQL's markers)
|
||||
/// delegates to <see cref="SqliteDialect"/>, and Microsoft.Data.Sqlite accepts both a <c>:</c>-prefixed
|
||||
/// marker and a prefix-less parameter name — so a poll or a catalog load driven through this dialect runs
|
||||
/// against the real fixture database rather than being asserted on as text.</para>
|
||||
/// <para>A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and
|
||||
/// <c>new</c>-hiding a member would leave interface dispatch calling the original — the substitution
|
||||
/// would silently not happen and the control would pass for the wrong reason.</para>
|
||||
/// </summary>
|
||||
public sealed class ColonMarkerDialect : ISqlDialect
|
||||
{
|
||||
private static readonly SqliteDialect Inner = new();
|
||||
|
||||
/// <summary>Every command handed to <see cref="ConfigureCommand"/>, kept so its binds can be read back.</summary>
|
||||
private readonly List<DbCommand> _commands = [];
|
||||
|
||||
/// <summary>How many times <see cref="ConfigureCommand"/> has been called on this instance.</summary>
|
||||
/// <remarks>
|
||||
/// Oracle's <c>BindByName</c> is set here, and a command created without it mis-binds by position
|
||||
/// rather than failing — so "was every command configured" has to be observable to be testable.
|
||||
/// </remarks>
|
||||
public int ConfiguredCommands
|
||||
{
|
||||
get { lock (_commands) return _commands.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="DbParameter.ParameterName"/> of every parameter bound on every configured command,
|
||||
/// read after the fact.
|
||||
/// <para><b>Why this and not the query's own success.</b> Microsoft.Data.Sqlite resolves a parameter
|
||||
/// whether it is named <c>:k0</c> or bare <c>k0</c>, so a poll that returns the right values proves
|
||||
/// the SQL parsed — not that the caller bound the dialect's bind name. Only the name actually put on
|
||||
/// the parameter distinguishes the two, and on ODP.NET the wrong one is a hard bind failure.</para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BoundParameterNames
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_commands)
|
||||
return _commands
|
||||
.SelectMany(c => c.Parameters.Cast<DbParameter>())
|
||||
.Select(p => p.ParameterName)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => Inner.Provider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => Inner.Factory;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => Inner.LivenessSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListTablesSql => Colonize(Inner.ListTablesSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListColumnsSql => Colonize(Inner.ListColumnsSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ParameterMarker(string baseName) => ":" + baseName;
|
||||
|
||||
/// <summary>The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker.</summary>
|
||||
/// <param name="baseName">The planner's/loader's base name.</param>
|
||||
/// <returns>The bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => baseName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
lock (_commands) _commands.Add(command);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the SQLite dialect's <c>@</c> markers as <c>:</c> markers. The catalog SQL is dialect-owned
|
||||
/// text, so a colon-marker dialect must carry colon markers in it — that is precisely the coupling the
|
||||
/// catalog loader's bind names have to stay in step with.
|
||||
/// </summary>
|
||||
private static string Colonize(string sql) => sql.Replace('@', ':');
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
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 <see cref="OracleDialect"/>, mirroring <see cref="SqlServerDialectTests"/>: the
|
||||
/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
|
||||
/// Oracle needs, pinning <c>OracleCommand.BindByName</c>.
|
||||
/// </summary>
|
||||
public class OracleDialectTests
|
||||
{
|
||||
// ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
|
||||
|
||||
/// <summary>
|
||||
/// The sharpest correctness edge on the whole provider seam. A real <see cref="OracleCommand"/> — no
|
||||
/// connection needed — must report <c>BindByName == false</c> before
|
||||
/// <see cref="OracleDialect.ConfigureCommand"/> touches it and <c>true</c> afterwards.
|
||||
/// <para><b>The false-by-default half is the falsifiability control.</b> Asserting only the post-state
|
||||
/// would pass identically against an ODP.NET release that had flipped the default — i.e. it would prove
|
||||
/// nothing about whether this dialect's call is load-bearing. Pinning both halves means the day the
|
||||
/// provider default changes, this test says so instead of quietly becoming a tautology.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigureCommand_turnsOnBindByName_whichOdpNetLeavesOffByDefault()
|
||||
{
|
||||
using var command = new OracleCommand();
|
||||
|
||||
command.BindByName.ShouldBeFalse(
|
||||
"ODP.NET binds by position by default — if this ever changes, the dialect's ConfigureCommand " +
|
||||
"is no longer what makes named binding work and this test must be re-reasoned, not deleted.");
|
||||
|
||||
new OracleDialect().ConfigureCommand(command);
|
||||
|
||||
command.BindByName.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A command from another provider passes through untouched and, crucially, does <b>not</b> throw:
|
||||
/// the dialect can legitimately meet a foreign <c>DbCommand</c> (an instrumented or substituted
|
||||
/// factory in a shared test harness), and an <c>InvalidCastException</c> there would fail a poll for a
|
||||
/// reason that has nothing to do with the database.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigureCommand_leavesAForeignCommandUntouched()
|
||||
{
|
||||
using var command = new SqliteCommand("SELECT 1");
|
||||
|
||||
Should.NotThrow(() => new OracleDialect().ConfigureCommand(command));
|
||||
|
||||
command.CommandText.ShouldBe("SELECT 1");
|
||||
command.Parameters.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParameterMarker_isColonPrefixed_andTheBindNameIsTheSameNameWithoutTheColon()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings.
|
||||
d.ParameterMarker("k0").ShouldBe(":p_k0");
|
||||
d.ParameterBindName("k0").ShouldBe("p_k0");
|
||||
d.ParameterMarker("schema").ShouldBe(":p_schema");
|
||||
d.ParameterBindName("schema").ShouldBe("p_schema");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No bind name may be spelled as its bare base name — the <c>p_</c> namespace is what stops one
|
||||
/// colliding with an Oracle reserved word.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is a regression test for a live-found, provider-fatal defect.</b> The catalog readers bind
|
||||
/// a parameter whose base name is <c>table</c>, and Oracle rejects a bind variable named after a
|
||||
/// reserved word outright: <c>ORA-01745: invalid host/bind variable name</c>. With the un-prefixed
|
||||
/// names this dialect first shipped with, <c>ListColumnsSql</c> could not execute at all — so every
|
||||
/// Oracle driver instance failed its Initialize-time catalog load and rejected every authored tag, and
|
||||
/// every AdminUI browse of a table's columns threw. It was invisible to every offline test (only a
|
||||
/// server parses the SQL) and was caught by <c>OracleReadTests</c> against a real database.
|
||||
/// <para><c>table</c> is asserted explicitly because it is the one base name in use today that is
|
||||
/// actually reserved; the others guard the general rule.</para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("table")]
|
||||
[InlineData("schema")]
|
||||
[InlineData("k0")]
|
||||
public void ParameterBindName_neverSurfacesTheBareBaseName_soItCannotBeAReservedWord(string baseName)
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ParameterBindName(baseName).ShouldNotBe(baseName);
|
||||
d.ParameterMarker(baseName).ShouldNotBe(":" + baseName);
|
||||
// The two must stay derived from one another, or a statement's marker and its bind drift apart and
|
||||
// ODP.NET silently leaves the parameter unbound.
|
||||
d.ParameterMarker(baseName).ShouldBe(":" + d.ParameterBindName(baseName));
|
||||
}
|
||||
|
||||
// ---- identity ----
|
||||
|
||||
[Fact]
|
||||
public void Dialect_identifiesItselfAsOracle_andExposesTheAbstractFactory()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.Provider.ShouldBe(SqlProvider.Oracle);
|
||||
d.Factory.ShouldNotBeNull();
|
||||
d.Factory.ShouldBeOfType<OracleClientFactory>();
|
||||
}
|
||||
|
||||
// ---- quoting / injection boundary ----
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.QuoteIdentifier("SPEED").ShouldBe("\"SPEED\"");
|
||||
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); // " doubled, per Oracle's quoted-identifier rule
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
|
||||
=> new OracleDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\"");
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
|
||||
{
|
||||
// The only metacharacter that could terminate a quoted identifier early is ", and it is doubled.
|
||||
// The result is a single (nonexistent) identifier, never executable SQL.
|
||||
new OracleDialect().QuoteIdentifier("x\" ; DROP TABLE Users --")
|
||||
.ShouldBe("\"x\"\" ; DROP TABLE Users --\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident!));
|
||||
|
||||
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
|
||||
[Theory]
|
||||
[InlineData("x\0y")]
|
||||
[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 OracleDialect().QuoteIdentifier(ident));
|
||||
|
||||
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
|
||||
// these. They cannot escape the quotes, 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 OracleDialect().QuoteIdentifier(ident));
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
|
||||
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
|
||||
}
|
||||
|
||||
// ---- liveness + row limit ----
|
||||
|
||||
[Fact]
|
||||
public void LivenessSql_selectsFromDual()
|
||||
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// Oracle 12c+ spells the limit at the END of the statement, so the prefix must be empty — a
|
||||
// non-empty one would land between SELECT and the column list and produce invalid SQL.
|
||||
d.SingleRowLimitPrefix.ShouldBe("");
|
||||
d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY");
|
||||
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"A\" FROM \"T\"", d.SingleRowLimitSuffix)
|
||||
.ShouldBe("SELECT \"A\" FROM \"T\" FETCH FIRST 1 ROWS ONLY");
|
||||
}
|
||||
|
||||
// ---- catalog SQL ----
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_readsTheAllViews_neverInformationSchema()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// Oracle has no INFORMATION_SCHEMA at all; the ALL_* views are the portable equivalent.
|
||||
d.ListSchemasSql.ShouldContain("ALL_USERS");
|
||||
d.ListTablesSql.ShouldContain("ALL_TABLES");
|
||||
d.ListTablesSql.ShouldContain("ALL_VIEWS"); // INFORMATION_SCHEMA.TABLES includes views
|
||||
d.ListColumnsSql.ShouldContain("ALL_TAB_COLUMNS");
|
||||
d.DefaultSchemaSql.ShouldBe("SELECT sys_context('userenv','current_schema') FROM dual");
|
||||
|
||||
d.ListSchemasSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
d.ListTablesSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
d.ListColumnsSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isParameterizedWithColonMarkers_matchingParameterMarker()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
|
||||
|
||||
// A leftover @ marker would bind on SqlClient and silently be a literal here.
|
||||
d.ListTablesSql.ShouldNotContain("@");
|
||||
d.ListColumnsSql.ShouldNotContain("@");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>SqlCatalogLoader</c>/<c>SqlBrowseSession</c> project catalog columns by the
|
||||
/// <c>INFORMATION_SCHEMA</c> names, so every dialect must alias its columns to them or
|
||||
/// <c>GetOrdinal</c> throws at runtime — a failure the SQL text alone would not reveal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CatalogSql_aliasesItsColumnsToTheInformationSchemaNamesTheReadersProjectBy()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ListSchemasSql.ShouldContain("TABLE_SCHEMA");
|
||||
d.ListTablesSql.ShouldContain("TABLE_NAME");
|
||||
d.ListTablesSql.ShouldContain("TABLE_TYPE");
|
||||
d.ListColumnsSql.ShouldContain("COLUMN_NAME");
|
||||
d.ListColumnsSql.ShouldContain("DATA_TYPE");
|
||||
|
||||
// SqlBrowseSession compares TABLE_TYPE against the literal "VIEW" to mark a view node.
|
||||
d.ListTablesSql.ShouldContain("'VIEW'");
|
||||
d.ListTablesSql.ShouldContain("'BASE TABLE'");
|
||||
}
|
||||
|
||||
// ---- type map ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("NUMBER", DriverDataType.Float64)]
|
||||
[InlineData("FLOAT", DriverDataType.Float64)]
|
||||
[InlineData("BINARY_DOUBLE", DriverDataType.Float64)]
|
||||
[InlineData("BINARY_FLOAT", DriverDataType.Float32)]
|
||||
[InlineData("INTEGER", DriverDataType.Float64)]
|
||||
[InlineData("VARCHAR2", DriverDataType.String)]
|
||||
[InlineData("NVARCHAR2", DriverDataType.String)]
|
||||
[InlineData("CHAR", DriverDataType.String)]
|
||||
[InlineData("NCHAR", DriverDataType.String)]
|
||||
[InlineData("CLOB", DriverDataType.String)]
|
||||
[InlineData("NCLOB", DriverDataType.String)]
|
||||
[InlineData("DATE", DriverDataType.DateTime)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// <c>ALL_TAB_COLUMNS.DATA_TYPE</c> carries the fractional-seconds precision and the time-zone
|
||||
/// qualifier <em>inside</em> the family name, unlike <c>INFORMATION_SCHEMA</c>. An exact-match table
|
||||
/// would send every one of these to the String fallback.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("TIMESTAMP")]
|
||||
[InlineData("TIMESTAMP(6)")]
|
||||
[InlineData("TIMESTAMP(3)")]
|
||||
[InlineData("TIMESTAMP(6) WITH TIME ZONE")]
|
||||
[InlineData("TIMESTAMP(6) WITH LOCAL TIME ZONE")]
|
||||
public void MapColumnType_matchesTheTimestampFamilyByPrefix(string sql)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(DriverDataType.DateTime);
|
||||
|
||||
[Theory]
|
||||
[InlineData("number", DriverDataType.Float64)]
|
||||
[InlineData("VarChar2", DriverDataType.String)]
|
||||
[InlineData("timestamp(6)", DriverDataType.DateTime)]
|
||||
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("RAW")] // binary, like SQL Server's varbinary
|
||||
[InlineData("BLOB")]
|
||||
[InlineData("LONG RAW")]
|
||||
[InlineData("ROWID")]
|
||||
[InlineData("XMLTYPE")]
|
||||
[InlineData("INTERVAL DAY(3) TO SECOND(6)")] // a duration, deliberately NOT a DateTime
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
|
||||
=> new OracleDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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>
|
||||
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> rule for
|
||||
/// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline,
|
||||
/// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a
|
||||
/// 63-character identifier ceiling, the end-of-statement <c>LIMIT 1</c>, and <c>bit</c> being a bit string
|
||||
/// rather than a boolean — the divergence is pinned here rather than left to reading.
|
||||
/// </summary>
|
||||
public class PostgresDialectTests
|
||||
{
|
||||
[Fact]
|
||||
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.QuoteIdentifier("Speed").ShouldBe("\"Speed\"");
|
||||
// " doubled, per the SQL-standard rule Postgres follows.
|
||||
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_rejectsControlCharsAndNul()
|
||||
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier("x\0y"));
|
||||
|
||||
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The
|
||||
// code points are spelled numerically so the offending characters never appear raw in this source file.
|
||||
[Theory]
|
||||
[InlineData(0x0001)] // C0 SOH
|
||||
[InlineData(0x001B)] // C0 ESC
|
||||
[InlineData(0x007F)] // DEL
|
||||
[InlineData(0x0085)] // C1 NEL
|
||||
[InlineData(0x0009)] // TAB
|
||||
[InlineData(0x000A)] // LF
|
||||
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint)
|
||||
=> Should.Throw<ArgumentException>(
|
||||
() => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y"));
|
||||
|
||||
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
|
||||
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
|
||||
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
|
||||
[Theory]
|
||||
[InlineData(0x200B)] // zero-width space
|
||||
[InlineData(0x202E)] // right-to-left override
|
||||
[InlineData(0x2066)] // left-to-right isolate
|
||||
[InlineData(0xFEFF)] // BOM / zero-width no-break space
|
||||
[InlineData(0x00AD)] // soft hyphen
|
||||
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
|
||||
=> Should.Throw<ArgumentException>(
|
||||
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier(ident!));
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
|
||||
=> new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name "
|
||||
|
||||
// NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it
|
||||
// is a config mistake rather than something to quote and send.
|
||||
[Fact]
|
||||
public void QuoteIdentifier_acceptsExactly63Chars_andRejects64()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\"");
|
||||
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
|
||||
{
|
||||
// A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it
|
||||
// is doubled. The result is a single (nonexistent) identifier, never executable SQL.
|
||||
new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --")
|
||||
.ShouldBe("\"x\"\" ; DROP TABLE users --\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("boolean", DriverDataType.Boolean)]
|
||||
[InlineData("smallint", DriverDataType.Int16)]
|
||||
[InlineData("integer", DriverDataType.Int32)]
|
||||
[InlineData("bigint", DriverDataType.Int64)]
|
||||
[InlineData("real", DriverDataType.Float32)]
|
||||
[InlineData("double precision", DriverDataType.Float64)]
|
||||
[InlineData("numeric", DriverDataType.Float64)]
|
||||
[InlineData("text", DriverDataType.String)]
|
||||
[InlineData("character varying", DriverDataType.String)]
|
||||
[InlineData("character", DriverDataType.String)]
|
||||
[InlineData("uuid", DriverDataType.String)]
|
||||
[InlineData("timestamp without time zone", DriverDataType.DateTime)]
|
||||
[InlineData("timestamp with time zone", DriverDataType.DateTime)]
|
||||
[InlineData("date", DriverDataType.DateTime)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("BOOLEAN", DriverDataType.Boolean)]
|
||||
[InlineData("Double Precision", DriverDataType.Float64)]
|
||||
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
|
||||
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back
|
||||
[InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean
|
||||
[InlineData("bit varying")]
|
||||
[InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`)
|
||||
[InlineData("interval")]
|
||||
[InlineData("json")]
|
||||
[InlineData("ARRAY")]
|
||||
[InlineData("USER-DEFINED")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
|
||||
=> new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isInformationSchemaAndParameterized()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.LivenessSql.ShouldBe("SELECT 1");
|
||||
d.DefaultSchemaSql.ShouldBe("SELECT current_schema()");
|
||||
d.ListTablesSql.ShouldContain("information_schema.tables");
|
||||
d.ListColumnsSql.ShouldContain("information_schema.columns");
|
||||
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain("@schema");
|
||||
d.ListColumnsSql.ShouldContain("@table");
|
||||
}
|
||||
|
||||
// The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the
|
||||
// same string, or the loader binds a name the statement never mentions.
|
||||
[Fact]
|
||||
public void CatalogSql_markersAgreeWithParameterMarker()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListSchemasSql_excludesTheSystemSchemas()
|
||||
{
|
||||
var sql = new PostgresDialect().ListSchemasSql;
|
||||
sql.ShouldContain("'pg_catalog'");
|
||||
sql.ShouldContain("'information_schema'");
|
||||
|
||||
// The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so
|
||||
// they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail
|
||||
// if someone "tidies" the backslash away and silently widens the exclusion.
|
||||
sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'");
|
||||
sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'");
|
||||
}
|
||||
|
||||
// The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and
|
||||
// Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and
|
||||
// this pins the exact ones the consumers ask for.
|
||||
[Fact]
|
||||
public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\"");
|
||||
d.ListTablesSql.ShouldContain("\"TABLE_NAME\"");
|
||||
d.ListTablesSql.ShouldContain("\"TABLE_TYPE\"");
|
||||
d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\"");
|
||||
d.ListColumnsSql.ShouldContain("\"DATA_TYPE\"");
|
||||
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
|
||||
// Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The
|
||||
// planner appends the suffix straight onto the finished statement, so the space belongs to it.
|
||||
d.SingleRowLimitPrefix.ShouldBe("");
|
||||
d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1");
|
||||
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix)
|
||||
.ShouldBe("SELECT \"a\" LIMIT 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.Provider.ShouldBe(SqlProvider.Postgres);
|
||||
d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance);
|
||||
// The seam must not leak Npgsql into its signature.
|
||||
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
|
||||
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("k0")]
|
||||
[InlineData("w")]
|
||||
[InlineData("schema")]
|
||||
public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName)
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ParameterMarker(baseName).ShouldBe("@" + baseName);
|
||||
d.ParameterBindName(baseName).ShouldBe("@" + baseName);
|
||||
}
|
||||
|
||||
// Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a
|
||||
// command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory).
|
||||
[Fact]
|
||||
public void ConfigureCommand_leavesAForeignCommandUntouched()
|
||||
{
|
||||
using var command = new SqliteCommand("SELECT 1");
|
||||
command.Parameters.AddWithValue("@k0", 7);
|
||||
|
||||
Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command));
|
||||
|
||||
command.CommandText.ShouldBe("SELECT 1");
|
||||
command.Parameters.Count.ShouldBe(1);
|
||||
command.Parameters[0].ParameterName.ShouldBe("@k0");
|
||||
command.Parameters[0].Value.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
|
||||
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
|
||||
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
|
||||
/// carries.
|
||||
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
|
||||
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
|
||||
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags =
|
||||
[
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
|
||||
],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
dialect,
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// The catalog queries bound the bare names, and every catalog command went through the hook.
|
||||
dialect.BoundParameterNames.ShouldContain("schema");
|
||||
dialect.BoundParameterNames.ShouldContain("table");
|
||||
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
@@ -260,6 +304,12 @@ public sealed class SqlCatalogGateDriverTests
|
||||
|
||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||
|
||||
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
|
||||
|
||||
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
|
||||
|
||||
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
|
||||
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
|
||||
@@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests
|
||||
|
||||
// ---- provider gate ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> is still a
|
||||
/// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that
|
||||
/// had quietly become "construct anything" would pass every test below.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
|
||||
{
|
||||
@@ -130,12 +135,73 @@ public sealed class SqlDriverFactoryTests
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
|
||||
"d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain("Postgres");
|
||||
ex.Message.ShouldContain("MySql");
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
|
||||
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
|
||||
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
|
||||
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
|
||||
/// T-SQL at a Postgres server.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("SqlServer", typeof(SqlServerDialect))]
|
||||
[InlineData("Postgres", typeof(PostgresDialect))]
|
||||
[InlineData("Oracle", typeof(OracleDialect))]
|
||||
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var binding = SqlDriverFactoryExtensions.ParseBinding(
|
||||
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
|
||||
|
||||
binding.Dialect.ShouldBeOfType(expected);
|
||||
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
|
||||
/// bootstrapper actually calls — not only on the internal parse.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Postgres")]
|
||||
[InlineData("Oracle")]
|
||||
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Data.Common;
|
||||
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;
|
||||
|
||||
@@ -125,6 +126,54 @@ public sealed class SqlDriverProbeTests
|
||||
result.Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>production</b> dialect gate — the one seam every other test in this file bypasses.
|
||||
/// <para>Each case above builds its probe with <see cref="SqlDriverProbe.ForTest"/>, which injects
|
||||
/// <c>_ => dialect</c> and therefore never reaches <c>DefaultDialectFor</c>. That is exactly how the
|
||||
/// probe kept a SqlServer-only gate while the factory and the browser were widened to Postgres +
|
||||
/// Oracle: the whole suite stayed green, and AdminUI "Test Connect" failed for two of the three
|
||||
/// shipped providers with <c>InvalidOperationException</c>. Caught by the live chrome/AdminUI gate,
|
||||
/// not by any test — so this asserts the gate directly.</para>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.SqlServer)]
|
||||
[InlineData(SqlProvider.Postgres)]
|
||||
[InlineData(SqlProvider.Oracle)]
|
||||
public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider)
|
||||
=> SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider);
|
||||
|
||||
/// <summary>
|
||||
/// The falsification control for the Theory above: the two <b>reserved</b> members must still be
|
||||
/// rejected. Without this, widening the gate to "return a dialect for anything" would pass.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.MySql)]
|
||||
[InlineData(SqlProvider.Odbc)]
|
||||
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
|
||||
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))
|
||||
.Message.ShouldContain(provider.ToString());
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end through the <b>production constructor</b>: a Postgres config must get PAST dialect
|
||||
/// selection and fail at the connection attempt instead. Both outcomes are a red result whose message
|
||||
/// names only an exception type, so the type is the discriminator — with the SqlServer-only gate this
|
||||
/// is <c>InvalidOperationException</c> thrown before any socket is opened.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProductionProbe_postgresConfig_reachesTheConnectionAttempt_notTheProviderGate()
|
||||
{
|
||||
// Port 1 on the loopback: nothing listens, so the connect fails fast and locally — no fixture, no
|
||||
// network egress, and a failure that is the PROVIDER's, which is the point of the assertion.
|
||||
var name = SetRef("Host=127.0.0.1;Port=1;Database=x;Username=u;Password=p;Timeout=2");
|
||||
var result = await new SqlDriverProbe().ProbeAsync(
|
||||
$$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""",
|
||||
TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldNotContain(nameof(InvalidOperationException));
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>A config blob whose connectionStringRef resolves (for the life of the returned scope) to the
|
||||
|
||||
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
|
||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||
}
|
||||
|
||||
// ---- dialect-owned parameter markers ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for dialect-owned markers: against a <c>:</c>-marker dialect the emitted
|
||||
/// statement must carry <c>:k0</c>, and no <c>@</c> at all. A planner holding the T-SQL prefix in a
|
||||
/// const passes every SQL Server test in this class and fails only here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void KeyValueMarkers_comeFromTheDialect_ratherThanAHardcodedAtSign()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWideRowSelectorMarker_comesFromTheDialect_ratherThanAHardcodedAtSign()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("= :w");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bind names ride the plan <em>separately</em> from the markers, because on ODP.NET they are a
|
||||
/// different string. A plan that carried only markers would leave the reader deriving the bind name —
|
||||
/// the convention it is not entitled to know.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParameterBindNames_areTheDialects_andNeedNotEqualTheMarkers()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" }); // in the SQL text
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "k0", "k1" }); // on the DbParameter
|
||||
plans[0].ParameterBindNames.Count.ShouldBe(plans[0].Parameters.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWideRowSelectorBindName_isTheDialects_andNeedNotEqualTheMarker()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "w" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On a SqlClient-style dialect the two lists coincide — which is exactly why the colon control above
|
||||
/// exists, and why this one cannot stand in for it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OnSqlServer_theBindNamesAreTheMarkers()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
|
||||
new SqlServerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterBindNames.ShouldBe(plans[0].ParameterNames);
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "@k0" });
|
||||
}
|
||||
|
||||
// ---- wide row ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
|
||||
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- dialect-owned parameter naming ----
|
||||
|
||||
/// <summary>
|
||||
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
|
||||
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
|
||||
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
|
||||
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
|
||||
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
|
||||
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
var reader = new SqlPollReader(
|
||||
fixture.Factory, fixture.ConnectionString, dialect,
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||
|
||||
var snapshots = await reader.ReadAsync(
|
||||
["Speed", "Pressure", "Oven"], CancellationToken.None);
|
||||
|
||||
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
|
||||
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
|
||||
|
||||
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
|
||||
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
|
||||
|
||||
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
|
||||
// absence mis-binds an IN list by position instead of failing.
|
||||
dialect.ConfiguredCommands.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests
|
||||
public void Plan_doesNotAliasTheCallersLists()
|
||||
{
|
||||
var names = new List<string> { "@k0" };
|
||||
var bindNames = new List<string> { "@k0" };
|
||||
var values = new List<object?> { "v" };
|
||||
var members = new List<SqlTagDefinition>
|
||||
{
|
||||
@@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests
|
||||
};
|
||||
var selected = new List<string> { "k", "c" };
|
||||
|
||||
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
|
||||
var plan = new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, "gk", "SELECT 1", names, bindNames, values, members, selected);
|
||||
|
||||
names.Add("@k1");
|
||||
bindNames.Add("@k1");
|
||||
values.Add("other");
|
||||
members.Add(members[0]);
|
||||
selected.Add("extra");
|
||||
|
||||
plan.ParameterNames.Count.ShouldBe(1);
|
||||
plan.ParameterBindNames.Count.ShouldBe(1);
|
||||
plan.Parameters.Count.ShouldBe(1);
|
||||
plan.Members.Count.ShouldBe(1);
|
||||
plan.SelectedColumns.Count.ShouldBe(2);
|
||||
|
||||
@@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqliteFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
|
||||
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
|
||||
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
|
||||
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>Identical to the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>No-op: <c>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. The previous edition of this file asserted
|
||||
/// <c>Build("axis", 5) == "axis:5"</c> for four "parameter groups" that are not FOCAS
|
||||
/// addressing spaces — it pinned the exact string the driver could not parse, which is why
|
||||
/// a fully-green suite coexisted with a picker whose default value was unusable. Every case
|
||||
/// here therefore asserts the address ROUND-TRIPS through the driver's parser, not merely
|
||||
/// that it has the expected shape.
|
||||
/// </summary>
|
||||
public sealed class FocasAddressBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("axis", 5, "axis:5")]
|
||||
[InlineData("spindle", 0, "spindle:0")]
|
||||
[InlineData("program", 100, "program:100")]
|
||||
[InlineData("status", 1, "status:1")]
|
||||
public void Build_Canonical(string group, int parameterId, string expected)
|
||||
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 100, null, "R100")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "X", 0, 0, "X0.0")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "F", 20, 3, "F20.3")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "r", 100, null, "R100")] // letter is normalised
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1020, null, "PARAM:1020")]
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 0, "PARAM:1815/0")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, null, "MACRO:500")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, 3, "MACRO:500")] // macros have no bit form
|
||||
public void Build_produces_an_address_the_driver_can_parse(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit, string expected)
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(kind, letter, number, bit);
|
||||
|
||||
built.ShouldBe(expected);
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "Z", 10, null)] // not a PMC area letter
|
||||
[InlineData(FocasManualAddressKind.Pmc, "", 10, null)] // no area chosen
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 10, 8)] // PMC bits are 0-7
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", -1, null)] // negative byte
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 32)] // parameter bits are 0-31
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, -5, null)]
|
||||
public void Build_returns_empty_rather_than_an_unusable_address(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit)
|
||||
=> FocasAddressBuilder.Build(kind, letter, number, bit).ShouldBeEmpty();
|
||||
|
||||
/// <summary>
|
||||
/// The contract that matters, stated over the whole offered surface rather than a sample:
|
||||
/// nothing the picker can compose is unreadable. Every PMC area the picker lists, at a
|
||||
/// whole-word address and at each legal bit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_offered_pmc_area_composes_a_parseable_address()
|
||||
{
|
||||
foreach (var letter in FocasAddressBuilder.PmcLetters)
|
||||
{
|
||||
foreach (int? bit in new int?[] { null, 0, 7 })
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(FocasManualAddressKind.Pmc, letter, 12, bit);
|
||||
|
||||
built.ShouldNotBeEmpty($"PMC area '{letter}' bit '{bit}' composed nothing");
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull($"'{built}' does not parse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,36 @@ public sealed class FocasTagConfigModelTests
|
||||
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. <c>Validate</c> used to accept any non-blank string, so a browse full
|
||||
/// reference sailed through authoring, through the deploy gate, and was then skipped at
|
||||
/// driver init — where the only trace was a Debug-level log line nobody reads. The first
|
||||
/// case here is verbatim what the AdminUI showed for one of the 20 dead tags on the Makino.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")]
|
||||
[InlineData("Z99")] // Z is not a PMC area letter
|
||||
[InlineData("PARAM:notanumber")]
|
||||
[InlineData("FIXED:")]
|
||||
public void Validate_rejects_an_address_the_driver_would_skip(string address)
|
||||
{
|
||||
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
|
||||
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
// The operator has to be able to act on it, so the message names the legal forms.
|
||||
error.ShouldContain("PARAM:");
|
||||
error.ShouldContain("FIXED:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -107,6 +108,33 @@ public sealed class RawBrowseCommitMapperTests
|
||||
private static Dictionary<string, string> NativeTypeFields(string nativeType) =>
|
||||
new() { ["dataType"] = nativeType };
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
|
||||
/// than by which key it lands under.
|
||||
/// <para>
|
||||
/// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
|
||||
/// browser handed it, which was <c>{deviceHost}/{path}</c> and parses as nothing. The fix
|
||||
/// was in what the driver emits, so the check that keeps it fixed is that a leaf reference
|
||||
/// survives the commit AND passes the same validation the modal applies — a mapper test
|
||||
/// asserting only <c>o["address"] == reference</c> passed happily throughout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="reference">A leaf reference of the kind the FOCAS browser emits.</param>
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Spindle/S1/Load")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds")]
|
||||
[InlineData("MACRO:3901")]
|
||||
public void A_committed_FOCAS_leaf_is_an_address_the_authoring_gate_accepts(string reference)
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.FOCAS, reference);
|
||||
|
||||
var model = FocasTagConfigModel.FromJson(json);
|
||||
model.Address.ShouldBe(reference);
|
||||
model.Validate().ShouldBeNull($"'{reference}' committed to a tag the driver would skip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The committed <c>dataType</c> is the type the CONTROLLER reported, not the model default.
|
||||
/// AB CIP / TwinCAT / FOCAS decode the wire bytes by this field, so a browsed REAL left at the
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
@@ -86,4 +87,47 @@ public sealed class SqlDriverFormContractTests
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
|
||||
}
|
||||
|
||||
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
|
||||
|
||||
/// <summary>
|
||||
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
|
||||
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
|
||||
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
|
||||
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
|
||||
/// the next modal open.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.SqlServer, "SqlServer")]
|
||||
[InlineData(SqlProvider.Postgres, "Postgres")]
|
||||
[InlineData(SqlProvider.Oracle, "Oracle")]
|
||||
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
|
||||
{
|
||||
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
|
||||
|
||||
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
|
||||
|
||||
json.ShouldContain($"\"provider\":\"{name}\"");
|
||||
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
|
||||
var reloaded = SqlDriverForm.FormModel.FromDto(
|
||||
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
|
||||
reloaded.Provider.ShouldBe(provider);
|
||||
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer
|
||||
/// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has
|
||||
/// five members, the dropdown should too" edit has to argue with a test.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.MySql)]
|
||||
[InlineData(SqlProvider.Odbc)]
|
||||
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
|
||||
|
||||
json.ShouldContain($"\"provider\":\"{reserved}\"");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user