diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index d2140099..16402154 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -57,6 +57,9 @@ case DriverTypeNames.Galaxy: break; + case DriverTypeNames.Sql: + + break; default:
No typed config form for driver type @_driverType.
break; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor index 5bf707fc..8b90303c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor @@ -36,6 +36,7 @@ +
Cannot be changed after creation — drives the actor type that owns this instance.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor new file mode 100644 index 00000000..744aee9f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor @@ -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 *@ +
+
Database connection
+
+
+
+ + + @* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@ + + +
Only Microsoft SQL Server is supported in v1.
+
+
+ + +
+ Name of the Sql__ConnectionStrings__<ref> env var set on the driver + AdminUI hosts — + not a connection string. The credential is resolved at startup and never stored in the config. +
+ @if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef)) + { +
Connection string ref is required.
+ } +
+
+
+
+ +@* Polling / timeouts *@ +
+
Polling & timeouts
+
+
+
+ + +
Blank = factory default. Applied to groups without their own interval.
+
+
+ + +
Client-side per-query deadline. Should exceed the command timeout.
+
+
+ + +
ADO.NET server-side CommandTimeout backstop.
+
+
+ + +
Blank = factory default. Caps concurrent group queries (pool guard).
+
+
+
+
+ +@* Value handling *@ +
+
Value handling
+
+
+
+
+ + +
+
Unchecked = factory default (a NULL cell publishes Uncertain).
+
+
+
+ + +
+
+ Read-only v1 does not implement writes — this flag is inert and is not authored. +
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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; + } + } + + /// 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). + 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(json, _jsonOpts); } + catch { return null; } + } + + /// Flat mutable mirror of the driver-level fields of . 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. + 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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index e43dc14a..24b37fa2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -68,6 +68,7 @@ ("FOCAS", DriverTypeNames.FOCAS), ("OpcUaClient", DriverTypeNames.OpcUaClient), ("Galaxy", DriverTypeNames.Galaxy), + ("Sql", DriverTypeNames.Sql), ("Calculation", "Calculation"), ]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs new file mode 100644 index 00000000..8bc28e88 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -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; + +/// +/// Guards the AdminUI SqlDriverForm'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, ; and (2) the +/// serialization contract — provider as a NAME string (never an ordinal), camelCase keys, and no +/// composer-owned rawTags / inert allowWrites 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. +/// +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( + () => 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(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer); + } +}