feat(historian): SqlServerHistorianProvider + SqlServer backend registration
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+61
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IHistorianBackendProvider"/> for the SQL Server historian. Supports every
|
||||||
|
/// <see cref="HistorianValueKind"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlServerHistorianProvider : IHistorianBackendProvider
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlySet<HistorianValueKind> Kinds =
|
||||||
|
new HashSet<HistorianValueKind>(Enum.GetValues<HistorianValueKind>());
|
||||||
|
|
||||||
|
private readonly SqlHistorianConnection _conn;
|
||||||
|
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||||
|
private readonly SqlServerTagCatalog _catalog;
|
||||||
|
|
||||||
|
/// <summary>Creates the provider over validated options.</summary>
|
||||||
|
/// <param name="options">The <c>ServerHistorian:SqlServer</c> options.</param>
|
||||||
|
public SqlServerHistorianProvider(SqlServerHistorianOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_conn = new SqlHistorianConnection(options);
|
||||||
|
_bootstrap = new SqlServerSchemaBootstrapper(_conn, options);
|
||||||
|
_catalog = new SqlServerTagCatalog(_conn, _bootstrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "SqlServer";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlySet<HistorianValueKind> SupportedKinds => Kinds;
|
||||||
|
|
||||||
|
private static ILogger Log<T>(IServiceProvider sp) =>
|
||||||
|
((sp.GetService(typeof(ILoggerFactory)) as ILoggerFactory) ?? NullLoggerFactory.Instance).CreateLogger<T>();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IHistorianDataSource CreateDataSource(IServiceProvider sp) =>
|
||||||
|
new SqlServerHistorianDataSource(_conn, _bootstrap, _catalog, Log<SqlServerHistorianDataSource>(sp));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IHistorianValueWriter CreateValueWriter(IServiceProvider sp) =>
|
||||||
|
new SqlServerHistorianValueWriter(_conn, _bootstrap, _catalog, Log<SqlServerHistorianValueWriter>(sp));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) =>
|
||||||
|
new SqlServerAlarmHistorianWriter(_conn, _bootstrap, Log<SqlServerAlarmHistorianWriter>(sp));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IHistorianProvisioning CreateProvisioning(IServiceProvider sp) =>
|
||||||
|
new SqlServerTagProvisioner(_conn, _bootstrap, Log<SqlServerTagProvisioner>(sp));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp) =>
|
||||||
|
new SqlServerHistorianDataUpdater(_conn, _catalog);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
@@ -13,10 +14,22 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class HistorianBackendRegistry
|
public static class HistorianBackendRegistry
|
||||||
{
|
{
|
||||||
|
/// <summary>Backend name for the SQL Server historian.</summary>
|
||||||
|
public const string SqlServerBackendName = "SqlServer";
|
||||||
|
|
||||||
|
/// <summary>Binds the <c>ServerHistorian:SqlServer</c> section (shared by the registry and the validator).</summary>
|
||||||
|
public static SqlServerHistorianOptions BindSqlServer(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var o = new SqlServerHistorianOptions();
|
||||||
|
configuration.GetSection($"{ServerHistorianOptions.SectionName}:SqlServer").Bind(o);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>> Factories =
|
private static readonly IReadOnlyDictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>> Factories =
|
||||||
new Dictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
[ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o),
|
[ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o),
|
||||||
|
[SqlServerBackendName] = (_, c) => new SqlServerHistorianProvider(BindSqlServer(c)),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>The registered backend names, for validation messages.</summary>
|
/// <summary>The registered backend names, for validation messages.</summary>
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
|
|||||||
|
|
||||||
// Gateway connection checks apply only to the Gateway backend; other backends validate their own
|
// Gateway connection checks apply only to the Gateway backend; other backends validate their own
|
||||||
// options (added with the backend) here, under the same consumer gate.
|
// options (added with the backend) here, under the same consumer gate.
|
||||||
|
if (string.Equals(options.Backend?.Trim(), HistorianBackendRegistry.SqlServerBackendName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var error in HistorianBackendRegistry.BindSqlServer(_configuration).Validate())
|
||||||
|
builder.RequireThat(false, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.IsGatewayBackend) return;
|
if (!options.IsGatewayBackend) return;
|
||||||
|
|
||||||
var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)
|
var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||||
|
|||||||
+39
@@ -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<HistorianValueKind>(), 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<SqlServerHistorianDataSource>();
|
||||||
|
p.CreateValueWriter(sp).ShouldBeOfType<SqlServerHistorianValueWriter>();
|
||||||
|
p.CreateAlarmWriter(sp).ShouldBeOfType<SqlServerAlarmHistorianWriter>();
|
||||||
|
p.CreateProvisioning(sp).ShouldBeOfType<SqlServerTagProvisioner>();
|
||||||
|
p.CreateUpdater(sp).ShouldBeOfType<SqlServerHistorianDataUpdater>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullSp : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -306,4 +306,37 @@ public sealed class ServerHistorianOptionsValidatorTests
|
|||||||
() => _ = provider.GetRequiredService<IOptions<ServerHistorianOptions>>().Value);
|
() => _ = provider.GetRequiredService<IOptions<ServerHistorianOptions>>().Value);
|
||||||
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ServerHistorianOptionsValidator SqlSut(string? connectionString)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = "false" };
|
||||||
|
if (connectionString is not null) values["ServerHistorian:SqlServer:ConnectionString"] = connectionString;
|
||||||
|
return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SqlServer with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire.</summary>
|
||||||
|
[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 ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SqlServer with no connection string fails, naming the key.</summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unconsumed SqlServer section (nothing enabled) is not validated.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SqlServer_backend_not_consumed_passes_without_connection_string() =>
|
||||||
|
SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer" }).Failed.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -5,6 +5,8 @@ using Shouldly;
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
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.Host.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
@@ -59,4 +61,29 @@ public sealed class HistorianBackendRegistryTests
|
|||||||
p.CreateProvisioning(sp).ShouldBeOfType<GatewayTagProvisioner>();
|
p.CreateProvisioning(sp).ShouldBeOfType<GatewayTagProvisioner>();
|
||||||
p.CreateUpdater(sp).ShouldBeNull();
|
p.CreateUpdater(sp).ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IConfiguration SqlCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<SqlServerHistorianProvider>();
|
||||||
|
HistorianBackendRegistry.KnownBackends.ShouldContain("SqlServer");
|
||||||
|
p.SupportedKinds.Count.ShouldBe(6);
|
||||||
|
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
|
||||||
|
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
|
||||||
|
p.CreateDataSource(sp).ShouldBeOfType<SqlServerHistorianDataSource>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user