using System.Data.Common;
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
/// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the
/// dialect's SELECT 1 () under a bounded deadline, and report
/// green with latency or red with a reason. Mirrors ModbusDriverProbe.
/// Parses the SAME factory DTO with the SAME converter as
/// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
/// provider parses here exactly as it does at the factory, and the same
/// connectionStringRef resolves the same way.
/// Never throws (the contract). Every failure — malformed JSON, a
/// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a
/// timeout, a cancellation — becomes a red , so the AdminUI has nothing to
/// catch.
/// The resolved connection string never reaches the result message. A red result names the
/// failure class, never the string — the same credential discipline the factory and the driver keep.
///
public sealed class SqlDriverProbe : IDriverProbe
{
///
/// Selects the provider seam by . In production this constructs the real
/// ; injects a test dialect (and factory) so the
/// probe can run against the SQLite fixture with no SQL Server and no network.
///
private readonly Func _dialectFor;
///
/// Overrides the dialect's own when non-null — the injection point
/// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's
/// catalog SQL. Null in production: the dialect's own factory is used.
///
private readonly DbProviderFactory? _factoryOverride;
/// Initializes a new that constructs the real SQL Server dialect.
public SqlDriverProbe()
: this(DefaultDialectFor, factoryOverride: null)
{
}
private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride)
{
_dialectFor = dialectFor;
_factoryOverride = factoryOverride;
}
///
/// Test seam: a probe that always uses and opens connections from
/// , so the SQLite fixture's create → open → SELECT 1 → close cycle
/// runs unmodified.
///
/// The provider factory the probe opens connections from.
/// The dialect whose the probe runs.
/// A probe wired to the supplied factory + dialect.
/// A required argument is null.
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
return new SqlDriverProbe(_ => dialect, factory);
}
///
public string DriverType => SqlDriver.DriverTypeName;
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
SqlDriverConfigDto? dto;
try
{
dto = System.Text.Json.JsonSerializer.Deserialize(
configJson, SqlDriverFactoryExtensions.JsonOptions);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
{
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);
ISqlDialect dialect;
string connectionString;
try
{
dialect = _dialectFor(dto.Provider);
connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
}
catch (InvalidOperationException ex)
{
// Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
// safe and actionable (it names the environment variable the operator must set).
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
var factory = _factoryOverride ?? dialect.Factory;
return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
}
///
/// Opens one connection under a linked CTS bounded by , runs the dialect's
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
/// names the failure class — never the connection string (a provider exception can embed the
/// data source, so its .Message is deliberately not surfaced).
///
private static async Task RunLivenessAsync(
DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
CancellationToken ct)
{
var stopwatch = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);
try
{
var connection = factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = connectionString;
await connection.OpenAsync(cts.Token).ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
}
stopwatch.Stop();
return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return new DriverProbeResult(false, "Probe cancelled.", null);
}
catch (OperationCanceledException)
{
return new DriverProbeResult(
false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
// The provider's own message can carry the data source (host/database), which is not a secret but
// is more than the operator needs — and keeping the message to a fixed class is what guarantees no
// credential-bearing connection string can ever slip through. Name the exception TYPE only.
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
}
/// Constructs the production dialect for a provider; v1 supports SQL Server only.
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
_ => throw new InvalidOperationException(
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
};
}