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");
+ }
}