429ead837f
Widens the two provider gates so an authored `{"provider":"Postgres"}` or
`{"provider":"Oracle"}` binds that provider's own dialect instead of failing
"not available in this build":
- `SqlDriverFactoryExtensions.CreateDialect` gains a Postgres and an Oracle
arm. There is deliberately no fallback arm — a default resolving to
SqlServerDialect would construct happily and then emit T-SQL at a Postgres
or Oracle server. The rejection message now names the three supported and
the two still-reserved providers.
- `SqlDriverBrowser.DefaultDialectSelector` widens identically, so a provider
the runtime constructs is also browsable; MySql/Odbc keep resolving to null
and the caller keeps reporting them as unavailable.
Tests: the existing rejected-provider controls flip from Postgres to MySql
(factory) and Odbc (browser) so the reserved half stays falsifiable, and the
new legs assert the bound dialect TYPE rather than mere construction —
"no throw" alone would pass against a SqlServer fallback.
Doc-comment sweep (contract addition, one line each): the "v1 constructs
SqlServer only" / "reserved for P2" claims in SqlProvider, SqlDriverConfigDto,
ISqlDialect.Provider and SqlServerDialect are now false and were corrected in
the same change.
308 lines
18 KiB
C#
308 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
|
|
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
|
|
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
|
|
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
|
|
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
|
|
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
|
|
/// <c>operationTimeout</c>/<c>commandTimeout</c> 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.</para>
|
|
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
|
|
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
|
|
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
|
|
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
|
|
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
|
|
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
|
|
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
|
|
/// rendering — never the string itself.</para>
|
|
/// </summary>
|
|
public static class SqlDriverFactoryExtensions
|
|
{
|
|
/// <summary>
|
|
/// The driver-type string this factory registers under — chained off
|
|
/// <see cref="SqlDriver.DriverTypeName"/>, which is
|
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
|
|
/// </summary>
|
|
public const string DriverTypeName = SqlDriver.DriverTypeName;
|
|
|
|
/// <summary>
|
|
/// How a <c>Sql</c> config blob is read.
|
|
/// <list type="bullet">
|
|
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
|
|
/// ordinal, and always writes the name (the enum-serialization guard).</item>
|
|
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
|
|
/// schema version must not brick the driver.</item>
|
|
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
|
|
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
|
|
/// what makes a <em>write</em> round-trip to the authored shape.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
internal static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
|
|
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
|
|
|
|
/// <summary>
|
|
/// Registers the <c>Sql</c> factory with the driver registry. The optional
|
|
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
|
|
/// driver instance; without it the driver runs with the null logger.
|
|
/// </summary>
|
|
/// <param name="registry">The driver factory registry to register with.</param>
|
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
|
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
|
|
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
|
}
|
|
|
|
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
|
|
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
|
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
|
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
|
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
|
|
|
/// <summary>
|
|
/// Parses a Sql <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
|
|
/// from config: the typed options, the <see cref="ISqlDialect"/> the <c>provider</c> key selects, and
|
|
/// the connection string resolved from <c>connectionStringRef</c>.
|
|
/// <para>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.</para>
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
|
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
|
/// <returns>The parsed options, dialect and resolved connection string.</returns>
|
|
/// <exception cref="InvalidOperationException">The config is malformed or names no connection string.</exception>
|
|
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}<ref>' 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
|
|
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
|
|
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
|
|
/// exist until the driver does.</para>
|
|
/// <para>No I/O happens here. The database is first contacted by
|
|
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
|
|
/// Reconnecting rather than a deployment that cannot be constructed.</para>
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
|
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
|
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
|
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
|
|
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
|
|
/// </exception>
|
|
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<SqlDriver>(),
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
|
|
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<SqlDriverConfigDto>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the provider seam.
|
|
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
|
|
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
|
|
/// <see cref="SqlProvider.Odbc"/> 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.</para>
|
|
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
|
|
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
|
|
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
|
|
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
|
|
/// </summary>
|
|
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)}'."),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
/// <param name="dto">The deserialised config blob.</param>
|
|
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
|
|
/// <returns>The typed options the driver is constructed with.</returns>
|
|
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
|
|
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.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Everything <c>SqlDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
|
|
/// reinitialize adopts all of it atomically.
|
|
/// <para>Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
|
|
/// connection string are both config-derived, so adopting new <see cref="Options"/> alone would poll a
|
|
/// NEW tag set against the OLD database (Gitea #516).</para>
|
|
/// </summary>
|
|
/// <param name="Options">The typed driver options.</param>
|
|
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
|
|
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
|
|
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
|
|
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
|
|
public sealed record SqlDriverBinding(
|
|
SqlDriverOptions Options,
|
|
ISqlDialect Dialect,
|
|
string ConnectionString,
|
|
SqlDriverConfigDto Dto);
|