diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianProvider.cs new file mode 100644 index 00000000..f59dae2c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianProvider.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; + +/// +/// for the SQL Server historian. Supports every +/// and HistoryUpdate. All seams share ONE connection helper, ONE schema +/// bootstrapper and ONE tag catalog, so the schema is ensured once and tag ids are cached once. +/// +public sealed class SqlServerHistorianProvider : IHistorianBackendProvider +{ + private static readonly IReadOnlySet Kinds = + new HashSet(Enum.GetValues()); + + private readonly SqlHistorianConnection _conn; + private readonly SqlServerSchemaBootstrapper _bootstrap; + private readonly SqlServerTagCatalog _catalog; + + /// Creates the provider over validated options. + /// The ServerHistorian:SqlServer options. + public SqlServerHistorianProvider(SqlServerHistorianOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _conn = new SqlHistorianConnection(options); + _bootstrap = new SqlServerSchemaBootstrapper(_conn, options); + _catalog = new SqlServerTagCatalog(_conn, _bootstrap); + } + + /// + public string Name => "SqlServer"; + + /// + public IReadOnlySet SupportedKinds => Kinds; + + private static ILogger Log(IServiceProvider sp) => + ((sp.GetService(typeof(ILoggerFactory)) as ILoggerFactory) ?? NullLoggerFactory.Instance).CreateLogger(); + + /// + public IHistorianDataSource CreateDataSource(IServiceProvider sp) => + new SqlServerHistorianDataSource(_conn, _bootstrap, _catalog, Log(sp)); + + /// + public IHistorianValueWriter CreateValueWriter(IServiceProvider sp) => + new SqlServerHistorianValueWriter(_conn, _bootstrap, _catalog, Log(sp)); + + /// + public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) => + new SqlServerAlarmHistorianWriter(_conn, _bootstrap, Log(sp)); + + /// + public IHistorianProvisioning CreateProvisioning(IServiceProvider sp) => + new SqlServerTagProvisioner(_conn, _bootstrap, Log(sp)); + + /// + public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp) => + new SqlServerHistorianDataUpdater(_conn, _catalog); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs index d9a2719e..ce68db91 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; @@ -13,10 +14,22 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// public static class HistorianBackendRegistry { + /// Backend name for the SQL Server historian. + public const string SqlServerBackendName = "SqlServer"; + + /// Binds the ServerHistorian:SqlServer section (shared by the registry and the validator). + public static SqlServerHistorianOptions BindSqlServer(IConfiguration configuration) + { + var o = new SqlServerHistorianOptions(); + configuration.GetSection($"{ServerHistorianOptions.SectionName}:SqlServer").Bind(o); + return o; + } + private static readonly IReadOnlyDictionary> Factories = new Dictionary>(StringComparer.OrdinalIgnoreCase) { [ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o), + [SqlServerBackendName] = (_, c) => new SqlServerHistorianProvider(BindSqlServer(c)), }; /// The registered backend names, for validation messages. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs index c045b907..e421747f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -71,6 +71,13 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SqlServerHistorianProviderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SqlServerHistorianProviderTests.cs new file mode 100644 index 00000000..48e159fc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SqlServerHistorianProviderTests.cs @@ -0,0 +1,39 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; + +public sealed class SqlServerHistorianProviderTests +{ + private static SqlServerHistorianProvider Make() => new(new SqlServerHistorianOptions + { + ConnectionString = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True", + }); + + [Fact] + public void Name_and_all_six_kinds() + { + var p = Make(); + p.Name.ShouldBe("SqlServer"); + p.SupportedKinds.ShouldBe(Enum.GetValues(), ignoreOrder: true); + p.SupportedKinds.Count.ShouldBe(6); + } + + [Fact] + public void Creates_all_five_seams_without_touching_sql() + { + var sp = new NullSp(); + var p = Make(); + p.CreateDataSource(sp).ShouldBeOfType(); + p.CreateValueWriter(sp).ShouldBeOfType(); + p.CreateAlarmWriter(sp).ShouldBeOfType(); + p.CreateProvisioning(sp).ShouldBeOfType(); + p.CreateUpdater(sp).ShouldBeOfType(); + } + + private sealed class NullSp : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs index 74537e1c..d6447d73 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -306,4 +306,37 @@ public sealed class ServerHistorianOptionsValidatorTests () => _ = provider.GetRequiredService>().Value); ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } + + private static ServerHistorianOptionsValidator SqlSut(string? connectionString) + { + var values = new Dictionary { ["AlarmHistorian:Enabled"] = "false" }; + if (connectionString is not null) values["ServerHistorian:SqlServer:ConnectionString"] = connectionString; + return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build()); + } + + /// SqlServer with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire. + [Fact] + public void SqlServer_backend_with_connection_string_passes_without_gateway_settings() + { + var result = SqlSut("Server=localhost;Database=h").Validate(null, + new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }); + + result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? [])); + } + + /// SqlServer with no connection string fails, naming the key. + [Fact] + public void SqlServer_backend_without_connection_string_fails_naming_the_key() + { + var result = SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:ConnectionString")); + result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint")); + } + + /// Unconsumed SqlServer section (nothing enabled) is not validated. + [Fact] + public void SqlServer_backend_not_consumed_passes_without_connection_string() => + SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer" }).Failed.ShouldBeFalse(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs index b2d9e3a2..9251df6a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs @@ -5,6 +5,8 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -59,4 +61,29 @@ public sealed class HistorianBackendRegistryTests p.CreateProvisioning(sp).ShouldBeOfType(); p.CreateUpdater(sp).ShouldBeNull(); } + + private static IConfiguration SqlCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True", + }).Build(); + + [Fact] + public void SqlServer_backend_resolves_and_builds_the_sql_seams() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "sqlserver", Enabled = true }, SqlCfg()); + + p.ShouldBeOfType(); + HistorianBackendRegistry.KnownBackends.ShouldContain("SqlServer"); + p.SupportedKinds.Count.ShouldBe(6); + p.SupportedKinds.ShouldContain(HistorianValueKind.String); + p.SupportedKinds.ShouldContain(HistorianValueKind.Empty); + p.CreateDataSource(sp).ShouldBeOfType(); + p.CreateValueWriter(sp).GetType().Name.ShouldBe("SqlServerHistorianValueWriter"); + p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("SqlServerAlarmHistorianWriter"); + p.CreateProvisioning(sp).GetType().Name.ShouldBe("SqlServerTagProvisioner"); + var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp)); + holder.Updater.ShouldNotBeNull(); + holder.Updater!.GetType().Name.ShouldBe("SqlServerHistorianDataUpdater"); + } }