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);