using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
/// Static factory registration helper for — the seam between a deployed
/// DriverConfig blob and a live driver instance. The Server's composition root calls
/// once at startup; the bootstrapper then materialises Sql DriverInstance
/// rows into driver instances. Mirrors ModbusDriverFactoryExtensions.
/// This type owns the config validation the layers below deliberately skip.
/// and stay usable with an inverted
/// operationTimeout/commandTimeout pair on purpose (the frozen-database tests need that
/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
/// its two independent deadlines. Every knob is checked here, once, at construction.
/// Enum fields are read leniently and written as names. carries a
/// , so a provider authored as an ordinal still parses and a
/// round-trip emits "SqlServer" — the systemic AdminUI defect where a page serialised an enum
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.
/// The resolved connection string is a secret and never leaves this method except into the
/// driver. Every log line and every exception message here names the connectionStringRef, the
/// environment variable, or 's credential-free server/database
/// rendering — never the string itself.
///
public static class SqlDriverFactoryExtensions
{
///
/// The driver-type string this factory registers under — chained off
/// , which is
/// .
///
public const string DriverTypeName = SqlDriver.DriverTypeName;
///
/// How a Sql config blob is read.
///
/// - — accepts an enum written as a name OR as an
/// ordinal, and always writes the name (the enum-serialization guard).
/// - — a blob authored against a different
/// schema version must not brick the driver.
/// - — the authored spelling
/// (connectionStringRef, rawTags). Reads are case-insensitive anyway; the policy is
/// what makes a write round-trip to the authored shape.
///
///
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// The serializer options, exposed so tests can assert the enum guard on a real round-trip.
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
///
/// Registers the Sql factory with the driver registry. The optional
/// is captured at registration time and used to build a logger per
/// driver instance; without it the driver runs with the null logger.
///
/// The driver factory registry to register with.
/// Optional logger factory for creating loggers per driver instance.
/// is null.
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// Builds one driver instance from its config blob. For the Server bootstrapper and tests.
/// The central config DB's identity for this driver instance.
/// The instance's DriverConfig JSON blob.
/// The constructed .
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
///
/// Parses a Sql DriverConfig JSON document into every dependency the driver derives
/// from config: the typed options, the the provider key selects, and
/// the connection string resolved from connectionStringRef.
/// Returned together because they must be adopted together. Re-parsing options alone on a
/// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a
/// half-applied config change is worse than a discarded one, because it looks like it worked.
/// Re-running this on reinitialize also re-resolves the connection string from its environment
/// variable, so a rotated credential is picked up without a process restart.
///
/// The central config DB's identity for this driver instance.
/// The driver configuration JSON.
/// The parsed options, dialect and resolved connection string.
/// The config is malformed or names no connection string.
internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}[' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
// that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
}
///
/// Logger-aware overload — used by 's closure when wired through DI.
/// Constructs the driver only: the driver builds its own , because
/// the reader's resolve delegate must read the driver's live authored-tag table, which does not
/// exist until the driver does.
/// No I/O happens here. The database is first contacted by
/// , so a database that is merely down yields a driver in
/// Reconnecting rather than a deployment that cannot be constructed.
///
/// The central config DB's identity for this driver instance.
/// The instance's DriverConfig JSON blob.
/// Optional logger factory for creating loggers per driver instance.
/// The constructed .
/// An argument is null, empty or whitespace.
///
/// The blob is not valid JSON, omits connectionStringRef, names a provider this build cannot
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
///
public static SqlDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
var binding = ParseBinding(driverInstanceId, driverConfigJson);
var dto = binding.Dto;
var dialect = binding.Dialect;
var connectionString = binding.ConnectionString;
var options = binding.Options;
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
if (dto.AllowWrites is true)
{
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
// on, however, can — so this is loud.
logger.LogWarning(
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
"driver is read-only (it implements no write capability), so no configuration can enable a " +
"write. Remove the flag, or expect reads only.",
driverInstanceId);
}
var driver = new SqlDriver(
options,
driverInstanceId,
dialect,
connectionString,
factory: null,
logger: loggerFactory?.CreateLogger(),
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options,
// dialect and connection string together on a reinitialize (#516). Re-resolving the connection
// string also picks up a rotated credential without a process restart.
rebind: json => ParseBinding(driverInstanceId, json));
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
// permitted outside the provider call.
logger.LogInformation(
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
options.DefaultPollInterval, options.OperationTimeout);
return driver;
}
/// Deserialises the blob, turning a malformed one into an actionable, instance-named failure.
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
{
try
{
return JsonSerializer.Deserialize(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' deserialised to null");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
}
}
///
/// Selects the provider seam.
/// This build constructs ,
/// and . and
/// 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.
/// Every arm returns that provider's OWN dialect — there is no fallback arm. A default
/// that resolved to would construct happily and then emit T-SQL
/// (INFORMATION_SCHEMA, TOP 1, @ markers) at a Postgres or Oracle server, which
/// fails at poll time as an unintelligible provider error rather than here as a config error.
///
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. Supported: '{nameof(SqlProvider.SqlServer)}', " +
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
};
///
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
/// Internal so the defaults and the rejections can be asserted directly, without a database or a
/// provisioned connection string standing between the test and the rule.
///
/// The deserialised config blob.
/// Named in every rejection message, so a fleet-wide log identifies the row.
/// The typed options the driver is constructed with.
/// A knob is out of range, or the two deadlines are inverted.
internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
{
ArgumentNullException.ThrowIfNull(dto);
var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
if (maxConcurrentGroups < 1)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
$"it must be at least 1.");
}
// Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
// server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
// wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
// first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
// two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
if (operationTimeout <= commandTimeout)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
$"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
$"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
$"never fire.");
}
return new SqlDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
DefaultPollInterval = defaultPollInterval,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
MaxConcurrentGroups = maxConcurrentGroups,
NullIsBad = dto.NullIsBad ?? false,
};
}
/// Rejects a non-positive duration, naming the authored field.
private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
{
if (value <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
}
}
}
///
/// Everything SqlDriver derives from its DriverConfig JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
/// connection string are both config-derived, so adopting new alone would poll a
/// NEW tag set against the OLD database (Gitea #516).
///
/// The typed driver options.
/// The provider seam the provider key selects.
/// The connection string resolved from connectionStringRef. Never logged.
/// The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
/// allowWrites, which the driver ignores but warns about) need not re-deserialize.
public sealed record SqlDriverBinding(
SqlDriverOptions Options,
ISqlDialect Dialect,
string ConnectionString,
SqlDriverConfigDto Dto);
]