feat(adminui): author + configure the Sql driver in /raw

The read-only Sql driver's runtime factory/probe and tag-side editors were
wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type
list, the DriverConfigModal switch, and the legacy identity dropdown all omitted
Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this.

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

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 20:41:33 -04:00
parent 585f827ef3
commit b295b24419
5 changed files with 292 additions and 0 deletions
@@ -57,6 +57,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -36,6 +36,7 @@
<option value="Focas">Focas</option>
<option value="OpcUaClient">OpcUaClient</option>
<option value="GalaxyMxGateway">Galaxy</option>
<option value="Sql">Sql</option>
</InputSelect>
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div>
@@ -0,0 +1,198 @@
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
factory's string-typed deserialize accepts them. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Database connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Provider</label>
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
</InputSelect>
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
</div>
<div class="col-md-8">
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="MesStaging" />
<div class="form-text">
Name of the <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var set on the driver + AdminUI hosts —
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
</div>
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
{
<div class="text-danger small mt-1">Connection string ref is required.</div>
}
</div>
</div>
</div>
</section>
@* Polling / timeouts *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Polling &amp; timeouts</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Default poll interval (s)</label>
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
</div>
<div class="col-md-3">
<label class="form-label">Command timeout (s)</label>
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max concurrent groups</label>
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
</div>
</div>
</div>
</section>
@* Value handling *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Value handling</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
</div>
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
</div>
<div class="form-text mt-0">
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
// drops the composer-owned rawTags (never authored here).
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
_form = FormModel.FromDto(dto);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static SqlDriverConfigDto? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
catch { return null; }
}
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
public sealed class FormModel
{
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
public string? ConnectionStringRef { get; set; }
public int? DefaultPollIntervalSeconds { get; set; }
public int? OperationTimeoutSeconds { get; set; }
public int? CommandTimeoutSeconds { get; set; }
public int? MaxConcurrentGroups { get; set; }
public bool NullIsBad { get; set; }
public static FormModel FromDto(SqlDriverConfigDto d) => new()
{
Provider = d.Provider,
ConnectionStringRef = d.ConnectionStringRef,
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
MaxConcurrentGroups = d.MaxConcurrentGroups,
NullIsBad = d.NullIsBad ?? false,
};
public SqlDriverConfigDto ToDto() => new()
{
Provider = Provider,
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
MaxConcurrentGroups = MaxConcurrentGroups,
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
// and rawTags are deliberately left null so they are omitted from the authored blob.
NullIsBad = NullIsBad ? true : null,
};
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
}
}
@@ -68,6 +68,7 @@
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
];
@@ -0,0 +1,89 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
/// </summary>
public sealed class SqlDriverFormContractTests
{
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
private static readonly JsonSerializerOptions FormOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
[Theory]
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
{
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
driver.ShouldNotBeNull();
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Factory_rejects_a_blob_missing_connectionStringRef()
{
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
[Fact]
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
{
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(dto, FormOptions);
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
}
[Fact]
public void Provider_round_trips_as_the_SqlServer_name()
{
var json = JsonSerializer.Serialize(
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
json.ShouldContain("\"SqlServer\"");
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
}
}