feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs

This commit is contained in:
Joseph Doherty
2026-08-05 06:13:36 -04:00
parent d1572a2b29
commit 08c1728a98
3 changed files with 71 additions and 7 deletions
+22 -5
View File
@@ -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
@@ -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>
@@ -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}\"");
}
}