Merge ph-T8 (pluggable historian)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
@@ -100,6 +101,8 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- OtOpcUa SQL Server historian backend - schema version 1.
|
||||
-- $(Schema) is substituted by SqlServerSchemaBootstrapper (identifier-validated) BEFORE the script is split on GO lines.
|
||||
-- Every statement is guarded so a re-run is a no-op.
|
||||
IF SCHEMA_ID(N'$(Schema)') IS NULL EXEC(N'CREATE SCHEMA [$(Schema)]')
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[SchemaVersion]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[SchemaVersion] (
|
||||
Version int NOT NULL PRIMARY KEY,
|
||||
AppliedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Tag]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Tag] (
|
||||
TagId int IDENTITY(1,1) NOT NULL PRIMARY KEY,
|
||||
Name nvarchar(400) NOT NULL CONSTRAINT UQ_Tag_Name UNIQUE,
|
||||
DataType tinyint NOT NULL,
|
||||
EngineeringUnit nvarchar(64) NULL,
|
||||
Description nvarchar(400) NULL,
|
||||
CreatedUtc datetime2(7) NOT NULL,
|
||||
UpdatedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Sample]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Sample] (
|
||||
TagId int NOT NULL,
|
||||
SourceTimeUtc datetime2(7) NOT NULL,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL,
|
||||
ServerTimeUtc datetime2(7) NOT NULL,
|
||||
CONSTRAINT PK_Sample PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc),
|
||||
CONSTRAINT FK_Sample_Tag FOREIGN KEY (TagId) REFERENCES [$(Schema)].[Tag](TagId))
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Event]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Event] (
|
||||
EventId nvarchar(64) NOT NULL CONSTRAINT PK_Event PRIMARY KEY NONCLUSTERED,
|
||||
SourceName nvarchar(400) NOT NULL,
|
||||
AlarmId nvarchar(400) NOT NULL,
|
||||
AlarmName nvarchar(400) NOT NULL,
|
||||
AlarmTypeName nvarchar(100) NOT NULL,
|
||||
Severity int NOT NULL,
|
||||
EventKind nvarchar(50) NOT NULL,
|
||||
Message nvarchar(max) NULL,
|
||||
UserName nvarchar(200) NULL,
|
||||
Comment nvarchar(max) NULL,
|
||||
EventTimeUtc datetime2(7) NOT NULL,
|
||||
ReceivedTimeUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'CX_Event_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE CLUSTERED INDEX CX_Event_Time ON [$(Schema)].[Event] (EventTimeUtc, EventId)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_Event_Source_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE INDEX IX_Event_Source_Time ON [$(Schema)].[Event] (SourceName, EventTimeUtc)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[SampleRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[SampleRow] AS TABLE (
|
||||
SourceTimeUtc datetime2(7) NOT NULL PRIMARY KEY,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[TimeRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[TimeRow] AS TABLE (T datetime2(7) NOT NULL PRIMARY KEY)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM [$(Schema)].[SchemaVersion] WHERE Version = 1)
|
||||
INSERT [$(Schema)].[SchemaVersion] (Version, AppliedUtc) VALUES (1, SYSUTCDATETIME())
|
||||
GO
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>One sample bound for a <c>SampleRow</c> TVP.</summary>
|
||||
/// <param name="TimeUtc">Source timestamp (UTC, full tick precision).</param>
|
||||
/// <param name="Value">Typed value.</param>
|
||||
/// <param name="StatusCode">OPC UA status code (stored bit-for-bit as int).</param>
|
||||
internal readonly record struct SqlSampleRow(DateTime TimeUtc, HistorianValue Value, uint StatusCode);
|
||||
|
||||
/// <summary>The (ValueKind, NumValue, IntValue, StrValue) column quad for a value.</summary>
|
||||
internal readonly record struct SampleColumns(byte ValueKind, double? NumValue, long? IntValue, string? StrValue);
|
||||
|
||||
/// <summary>
|
||||
/// HistorianValue ⇄ column mapping (spec §4) and the <c>SqlDataRecord</c> TVP builders. Mapping:
|
||||
/// Double → Num; Int64 → Int + Num(double); Boolean → Int 0/1 + Num 0/1; String → Str; DateTime → Int (ticks);
|
||||
/// Empty → all NULL.
|
||||
/// </summary>
|
||||
internal static class SampleRowMapper
|
||||
{
|
||||
private static readonly SqlMetaData[] SampleMeta =
|
||||
[
|
||||
new("SourceTimeUtc", SqlDbType.DateTime2, 0, 7),
|
||||
new("ValueKind", SqlDbType.TinyInt),
|
||||
new("NumValue", SqlDbType.Float),
|
||||
new("IntValue", SqlDbType.BigInt),
|
||||
new("StrValue", SqlDbType.NVarChar, SqlMetaData.Max),
|
||||
new("StatusCode", SqlDbType.Int),
|
||||
];
|
||||
|
||||
private static readonly SqlMetaData[] TimeMeta = [new("T", SqlDbType.DateTime2, 0, 7)];
|
||||
|
||||
/// <summary>Splits a value into its column quad.</summary>
|
||||
/// <param name="v">Typed value.</param>
|
||||
/// <returns>The columns to store.</returns>
|
||||
public static SampleColumns ToColumns(HistorianValue v) => v.Kind switch
|
||||
{
|
||||
HistorianValueKind.Double => new((byte)v.Kind, v.Number, null, null),
|
||||
HistorianValueKind.Int64 => new((byte)v.Kind, v.Number, v.Integer, null),
|
||||
HistorianValueKind.Boolean => new((byte)v.Kind, v.Integer != 0 ? 1d : 0d, v.Integer != 0 ? 1L : 0L, null),
|
||||
HistorianValueKind.String => new((byte)v.Kind, null, null, v.Text ?? ""),
|
||||
HistorianValueKind.DateTime => new((byte)v.Kind, null, v.Integer, null),
|
||||
_ => new((byte)HistorianValueKind.Empty, null, null, null),
|
||||
};
|
||||
|
||||
/// <summary>Rebuilds a value from stored columns.</summary>
|
||||
/// <param name="valueKind">ValueKind column.</param>
|
||||
/// <param name="num">NumValue column (null = NULL).</param>
|
||||
/// <param name="integer">IntValue column (null = NULL).</param>
|
||||
/// <param name="str">StrValue column (null = NULL).</param>
|
||||
/// <returns>The typed value; an unknown kind yields <see cref="HistorianValue.Empty"/>.</returns>
|
||||
public static HistorianValue FromColumns(byte valueKind, double? num, long? integer, string? str) => (HistorianValueKind)valueKind switch
|
||||
{
|
||||
HistorianValueKind.Double => HistorianValue.FromDouble(num ?? 0d),
|
||||
HistorianValueKind.Int64 => HistorianValue.FromInt64(integer ?? 0L),
|
||||
HistorianValueKind.Boolean => HistorianValue.FromBoolean((integer ?? 0L) != 0),
|
||||
HistorianValueKind.String => HistorianValue.FromString(str ?? ""),
|
||||
HistorianValueKind.DateTime => new HistorianValue(HistorianValueKind.DateTime, 0d, integer ?? 0L, null),
|
||||
_ => HistorianValue.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Stores a uint status bit-for-bit in the int column.</summary>
|
||||
/// <param name="status">Status code.</param>
|
||||
/// <returns>Same bits as int.</returns>
|
||||
public static int ToDbStatus(uint status) => unchecked((int)status);
|
||||
|
||||
/// <summary>Reads the int status column back to a uint code.</summary>
|
||||
/// <param name="dbStatus">Column value.</param>
|
||||
/// <returns>Same bits as uint.</returns>
|
||||
public static uint FromDbStatus(int dbStatus) => unchecked((uint)dbStatus);
|
||||
|
||||
/// <summary>Builds <c>SampleRow</c> TVP records (datetime2(7); never a DataTable). Lazy; enumerate once.</summary>
|
||||
/// <param name="rows">Samples; must be time-unique (the TVP has a PK on time) and non-empty when bound.</param>
|
||||
/// <returns>Records for <see cref="SqlHistorianConnection.AddStructured"/>.</returns>
|
||||
public static IEnumerable<SqlDataRecord> ToSampleRecords(IEnumerable<SqlSampleRow> rows)
|
||||
{
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var c = ToColumns(r.Value);
|
||||
var rec = new SqlDataRecord(SampleMeta);
|
||||
rec.SetDateTime(0, DateTime.SpecifyKind(r.TimeUtc, DateTimeKind.Utc));
|
||||
rec.SetByte(1, c.ValueKind);
|
||||
if (c.NumValue is { } n) rec.SetDouble(2, n); else rec.SetDBNull(2);
|
||||
if (c.IntValue is { } i) rec.SetInt64(3, i); else rec.SetDBNull(3);
|
||||
if (c.StrValue is { } s) rec.SetString(4, s); else rec.SetDBNull(4);
|
||||
rec.SetInt32(5, ToDbStatus(r.StatusCode));
|
||||
yield return rec;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds <c>TimeRow</c> TVP records (datetime2(7)). Lazy; enumerate once.</summary>
|
||||
/// <param name="timesUtc">Distinct UTC times; non-empty when bound.</param>
|
||||
/// <returns>Records for <see cref="SqlHistorianConnection.AddStructured"/>.</returns>
|
||||
public static IEnumerable<SqlDataRecord> ToTimeRecords(IEnumerable<DateTime> timesUtc)
|
||||
{
|
||||
foreach (var t in timesUtc)
|
||||
{
|
||||
var rec = new SqlDataRecord(TimeMeta);
|
||||
rec.SetDateTime(0, DateTime.SpecifyKind(t, DateTimeKind.Utc));
|
||||
yield return rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
-- OtOpcUa SQL Server historian backend - schema version 1.
|
||||
-- $(Schema) is substituted by SqlServerSchemaBootstrapper (identifier-validated) BEFORE the script is split on GO lines.
|
||||
-- Every statement is guarded so a re-run is a no-op.
|
||||
IF SCHEMA_ID(N'$(Schema)') IS NULL EXEC(N'CREATE SCHEMA [$(Schema)]')
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[SchemaVersion]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[SchemaVersion] (
|
||||
Version int NOT NULL PRIMARY KEY,
|
||||
AppliedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Tag]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Tag] (
|
||||
TagId int IDENTITY(1,1) NOT NULL PRIMARY KEY,
|
||||
Name nvarchar(400) NOT NULL CONSTRAINT UQ_Tag_Name UNIQUE,
|
||||
DataType tinyint NOT NULL,
|
||||
EngineeringUnit nvarchar(64) NULL,
|
||||
Description nvarchar(400) NULL,
|
||||
CreatedUtc datetime2(7) NOT NULL,
|
||||
UpdatedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Sample]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Sample] (
|
||||
TagId int NOT NULL,
|
||||
SourceTimeUtc datetime2(7) NOT NULL,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL,
|
||||
ServerTimeUtc datetime2(7) NOT NULL,
|
||||
CONSTRAINT PK_Sample PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc),
|
||||
CONSTRAINT FK_Sample_Tag FOREIGN KEY (TagId) REFERENCES [$(Schema)].[Tag](TagId))
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Event]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Event] (
|
||||
EventId nvarchar(64) NOT NULL CONSTRAINT PK_Event PRIMARY KEY NONCLUSTERED,
|
||||
SourceName nvarchar(400) NOT NULL,
|
||||
AlarmId nvarchar(400) NOT NULL,
|
||||
AlarmName nvarchar(400) NOT NULL,
|
||||
AlarmTypeName nvarchar(100) NOT NULL,
|
||||
Severity int NOT NULL,
|
||||
EventKind nvarchar(50) NOT NULL,
|
||||
Message nvarchar(max) NULL,
|
||||
UserName nvarchar(200) NULL,
|
||||
Comment nvarchar(max) NULL,
|
||||
EventTimeUtc datetime2(7) NOT NULL,
|
||||
ReceivedTimeUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'CX_Event_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE CLUSTERED INDEX CX_Event_Time ON [$(Schema)].[Event] (EventTimeUtc, EventId)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_Event_Source_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE INDEX IX_Event_Source_Time ON [$(Schema)].[Event] (SourceName, EventTimeUtc)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[SampleRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[SampleRow] AS TABLE (
|
||||
SourceTimeUtc datetime2(7) NOT NULL PRIMARY KEY,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[TimeRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[TimeRow] AS TABLE (T datetime2(7) NOT NULL PRIMARY KEY)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM [$(Schema)].[SchemaVersion] WHERE Version = 1)
|
||||
INSERT [$(Schema)].[SchemaVersion] (Version, AppliedUtc) VALUES (1, SYSUTCDATETIME())
|
||||
GO
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + command helpers shared by every SQL historian component.
|
||||
/// <para>Usage contract for writers/readers: call <see cref="SqlServerSchemaBootstrapper.EnsureAsync"/>
|
||||
/// once (it is cheap after the first success), then <see cref="OpenAsync"/>. Build SQL with <see cref="Q"/>
|
||||
/// for every object name and bind every timestamp through <see cref="AddDateTime2"/> or a TVP built by
|
||||
/// <see cref="SampleRowMapper"/> — never a <c>DataTable</c> (it infers 3.33 ms <c>datetime</c>).</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlHistorianConnection
|
||||
{
|
||||
private static readonly int[] TransientNumbers =
|
||||
[1205, -2, 40613, 40197, 40501, 49918, 4060, 233, 10053, 10054, 10060, 11001];
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>Creates the helper; options must already be valid.</summary>
|
||||
/// <param name="options">Validated options.</param>
|
||||
public SqlHistorianConnection(SqlServerHistorianOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!SqlServerHistorianOptions.IsValidSchemaName(options.Schema))
|
||||
throw new ArgumentException("Invalid schema name.", nameof(options));
|
||||
_connectionString = options.ConnectionString;
|
||||
Schema = options.Schema;
|
||||
CommandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(options.CommandTimeout.TotalSeconds));
|
||||
}
|
||||
|
||||
/// <summary>The validated schema name.</summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>Command timeout applied by <see cref="CreateCommand"/>, in seconds.</summary>
|
||||
public int CommandTimeoutSeconds { get; }
|
||||
|
||||
/// <summary>Schema-qualifies an object name: <c>[schema].[obj]</c>.</summary>
|
||||
/// <param name="obj">Unquoted object name (a fixed literal from this project, never user input).</param>
|
||||
/// <returns>The quoted two-part name.</returns>
|
||||
public string Q(string obj) => $"[{Schema}].[{obj}]";
|
||||
|
||||
/// <summary>Opens a new connection (no bootstrap; see the class remarks).</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>An open connection the caller disposes.</returns>
|
||||
public async Task<SqlConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var conn = new SqlConnection(_connectionString);
|
||||
try
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
return conn;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await conn.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a text command with the configured timeout.</summary>
|
||||
/// <param name="conn">Open connection.</param>
|
||||
/// <param name="sql">Command text.</param>
|
||||
/// <param name="tx">Optional transaction.</param>
|
||||
/// <returns>The command.</returns>
|
||||
public SqlCommand CreateCommand(SqlConnection conn, string sql, SqlTransaction? tx = null)
|
||||
{
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.CommandTimeout = CommandTimeoutSeconds;
|
||||
cmd.Transaction = tx;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>Adds a <c>datetime2(7)</c> scalar parameter (full 100 ns tick precision, UTC kind).</summary>
|
||||
/// <param name="cmd">Target command.</param>
|
||||
/// <param name="name">Parameter name including <c>@</c>.</param>
|
||||
/// <param name="value">Timestamp; treated as UTC.</param>
|
||||
/// <returns>The added parameter.</returns>
|
||||
public static SqlParameter AddDateTime2(SqlCommand cmd, string name, DateTime value)
|
||||
{
|
||||
var p = new SqlParameter(name, SqlDbType.DateTime2)
|
||||
{
|
||||
Scale = 7,
|
||||
Value = DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
};
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Adds a table-valued parameter of one of this schema's table types.</summary>
|
||||
/// <param name="cmd">Target command.</param>
|
||||
/// <param name="name">Parameter name including <c>@</c>.</param>
|
||||
/// <param name="typeName">Unqualified type name (<c>SampleRow</c> or <c>TimeRow</c>).</param>
|
||||
/// <param name="rows">Non-empty <c>SqlDataRecord</c> sequence (SqlClient rejects an empty one — skip the call).</param>
|
||||
/// <returns>The added parameter.</returns>
|
||||
public SqlParameter AddStructured(SqlCommand cmd, string name, string typeName,
|
||||
IEnumerable<Microsoft.Data.SqlClient.Server.SqlDataRecord> rows)
|
||||
{
|
||||
var p = new SqlParameter(name, SqlDbType.Structured) { TypeName = Q(typeName), Value = rows };
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Reads a <c>datetime2</c> column as a UTC <see cref="DateTime"/> (the driver returns Unspecified).</summary>
|
||||
/// <param name="reader">Reader positioned on a row.</param>
|
||||
/// <param name="ordinal">Column ordinal.</param>
|
||||
/// <returns>UTC timestamp.</returns>
|
||||
public static DateTime GetUtc(SqlDataReader reader, int ordinal) =>
|
||||
DateTime.SpecifyKind(reader.GetDateTime(ordinal), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>True for connection/timeout/deadlock/throttling errors worth retrying.</summary>
|
||||
/// <param name="ex">Exception from a SQL call.</param>
|
||||
/// <returns>Whether a retry may succeed.</returns>
|
||||
public static bool IsTransient(Exception ex)
|
||||
{
|
||||
if (ex is TimeoutException) return true;
|
||||
if (ex is not SqlException sql) return false;
|
||||
if (sql.IsTransient) return true;
|
||||
foreach (SqlError e in sql.Errors)
|
||||
if (Array.IndexOf(TransientNumbers, e.Number) >= 0) return true;
|
||||
return Array.IndexOf(TransientNumbers, sql.Number) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="ex"/> is a unique-key violation (2627/2601): "already present".</summary>
|
||||
/// <param name="ex">Exception from a SQL call.</param>
|
||||
/// <returns>Whether it is a duplicate-key error.</returns>
|
||||
public static bool IsDuplicateKey(Exception ex) =>
|
||||
ex is SqlException sql && sql.Errors.Cast<SqlError>().Any(e => e.Number is 2627 or 2601);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Every OPC UA status this backend emits, as named constants (inline literals are invisible to
|
||||
/// <c>StatusCodeParityTests</c>). Values mirror <c>Opc.Ua.StatusCodes</c>.
|
||||
/// </summary>
|
||||
internal static class SqlHistorianStatusCodes
|
||||
{
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodNoData = 0x00A50000u;
|
||||
public const uint GoodEntryInserted = 0x00A20000u;
|
||||
public const uint GoodEntryReplaced = 0x00A30000u;
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
public const uint BadEntryExists = 0x809F0000u;
|
||||
public const uint BadNoEntryExists = 0x80A00000u;
|
||||
public const uint BadInvalidTimestamp = 0x80230000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadAggregateInvalidInputs = 0x80D60000u;
|
||||
public const uint BadHistoryOperationUnsupported = 0x80720000u;
|
||||
public const uint UncertainDataSubNormal = 0x40A40000u;
|
||||
}
|
||||
|
||||
/// <summary>Part 4 §7.39 historian info bits OR-ed onto a status code.</summary>
|
||||
internal static class HistorianInfoBits
|
||||
{
|
||||
/// <summary>InfoType = DataValue.</summary>
|
||||
public const uint InfoTypeDataValue = 0x00000400u;
|
||||
|
||||
/// <summary>The value was produced by an aggregate calculation.</summary>
|
||||
public const uint HistorianCalculated = 0x1u;
|
||||
|
||||
/// <summary>The value was interpolated.</summary>
|
||||
public const uint HistorianInterpolated = 0x2u;
|
||||
|
||||
/// <summary>Marks <paramref name="status"/> as a Calculated data value.</summary>
|
||||
/// <param name="status">Base status code.</param>
|
||||
/// <returns>Status with InfoType and Calculated bits set.</returns>
|
||||
public static uint WithCalculated(uint status) => status | InfoTypeDataValue | HistorianCalculated;
|
||||
|
||||
/// <summary>Marks <paramref name="status"/> as an Interpolated data value.</summary>
|
||||
/// <param name="status">Base status code.</param>
|
||||
/// <returns>Status with InfoType and Interpolated bits set.</returns>
|
||||
public static uint WithInterpolated(uint status) => status | InfoTypeDataValue | HistorianInterpolated;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the SQL Server historian backend (the <c>ServerHistorian:SqlServer</c> config block).
|
||||
/// </summary>
|
||||
public sealed partial class SqlServerHistorianOptions
|
||||
{
|
||||
/// <summary>Required. ADO.NET connection string for the historian database (never the ConfigDb).</summary>
|
||||
public string ConnectionString { get; set; } = "";
|
||||
|
||||
/// <summary>SQL schema holding the historian objects. Spliced into DDL/DML, so it is regex-validated.</summary>
|
||||
public string Schema { get; set; } = "hist";
|
||||
|
||||
/// <summary>Per-command timeout. Must be positive.</summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> creates the schema and applies missing scripts on first use; <c>false</c> only verifies
|
||||
/// that schema version 1 exists and fails every historian call with a clear error otherwise.
|
||||
/// </summary>
|
||||
public bool AutoCreateSchema { get; set; } = true;
|
||||
|
||||
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$")]
|
||||
private static partial Regex IdentifierRegex();
|
||||
|
||||
/// <summary>True when <paramref name="schema"/> is a safe SQL identifier.</summary>
|
||||
/// <param name="schema">Candidate schema name.</param>
|
||||
/// <returns>Whether it matches <c>^[A-Za-z_][A-Za-z0-9_]{0,127}$</c>.</returns>
|
||||
public static bool IsValidSchemaName(string? schema) => schema is not null && IdentifierRegex().IsMatch(schema);
|
||||
|
||||
/// <summary>Validates the options without touching SQL.</summary>
|
||||
/// <returns>One message per problem; empty when valid.</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
errors.Add("ServerHistorian:SqlServer:ConnectionString is required.");
|
||||
if (!IsValidSchemaName(Schema))
|
||||
errors.Add("ServerHistorian:SqlServer:Schema must be a SQL identifier matching ^[A-Za-z_][A-Za-z0-9_]{0,127}$.");
|
||||
if (CommandTimeout <= TimeSpan.Zero)
|
||||
errors.Add("ServerHistorian:SqlServer:CommandTimeout must be greater than zero.");
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when <c>AutoCreateSchema=false</c> and the historian schema is absent or behind.</summary>
|
||||
public sealed class HistorianSchemaMissingException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception.</summary>
|
||||
/// <param name="message">Message naming the missing version.</param>
|
||||
public HistorianSchemaMissingException(string message) : base(message) { }
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Lazily applies the embedded, idempotent schema scripts once per instance, under a SQL application lock
|
||||
/// so concurrent nodes/instances serialise. A failure is NOT latched: the next <see cref="EnsureAsync"/> retries.
|
||||
/// With <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) >= 1</c>.
|
||||
/// </summary>
|
||||
internal sealed partial class SqlServerSchemaBootstrapper
|
||||
{
|
||||
/// <summary>The schema version this build requires.</summary>
|
||||
public const int RequiredVersion = 1;
|
||||
|
||||
private const string LockResource = "OtOpcUaHistorianSchema";
|
||||
private const int LockTimeoutMs = 30_000;
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly bool _autoCreate;
|
||||
private readonly ILogger _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _ready;
|
||||
|
||||
/// <summary>Creates the bootstrapper.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="options">Validated options.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
public SqlServerSchemaBootstrapper(SqlHistorianConnection conn, SqlServerHistorianOptions options, ILogger? log = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_autoCreate = options.AutoCreateSchema;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Ensures the schema is ready; no-op after the first success.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task completing when the schema is usable.</returns>
|
||||
/// <exception cref="HistorianSchemaMissingException">AutoCreateSchema is false and version 1 is absent.</exception>
|
||||
public async Task EnsureAsync(CancellationToken ct)
|
||||
{
|
||||
if (_ready) return;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_ready) return;
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false);
|
||||
else await VerifyAsync(conn, ct).ConfigureAwait(false);
|
||||
_ready = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ReadVersionAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn,
|
||||
$"IF OBJECT_ID(N'{_conn.Q("SchemaVersion")}', N'U') IS NULL SELECT 0 ELSE SELECT ISNULL(MAX(Version), 0) FROM {_conn.Q("SchemaVersion")}");
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private async Task VerifyAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
var v = await ReadVersionAsync(conn, ct).ConfigureAwait(false);
|
||||
if (v < RequiredVersion)
|
||||
throw new HistorianSchemaMissingException(
|
||||
$"Historian schema [{_conn.Schema}] is missing version {RequiredVersion} (found {v}) and AutoCreateSchema is false. " +
|
||||
"Apply docs/historian/sqlserver/001_initial.sql or enable AutoCreateSchema.");
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await AcquireLockAsync(conn, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var current = await ReadVersionAsync(conn, ct).ConfigureAwait(false);
|
||||
foreach (var (version, name, text) in LoadScripts())
|
||||
{
|
||||
if (version <= current) continue;
|
||||
foreach (var batch in SplitBatches(text.Replace("$(Schema)", _conn.Schema, StringComparison.Ordinal)))
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, batch);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
_log.LogInformation("Applied historian schema script {Script} to [{Schema}]", name, _conn.Schema);
|
||||
}
|
||||
await VerifyAsync(conn, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ReleaseLockAsync(conn).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquireLockAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, "sp_getapplock");
|
||||
cmd.CommandType = CommandType.StoredProcedure;
|
||||
cmd.CommandTimeout = Math.Max(cmd.CommandTimeout, LockTimeoutMs / 1000 + 15);
|
||||
cmd.Parameters.AddWithValue("@Resource", LockResource);
|
||||
cmd.Parameters.AddWithValue("@LockMode", "Exclusive");
|
||||
cmd.Parameters.AddWithValue("@LockOwner", "Session");
|
||||
cmd.Parameters.AddWithValue("@LockTimeout", LockTimeoutMs);
|
||||
var rc = new SqlParameter("@rc", SqlDbType.Int) { Direction = ParameterDirection.ReturnValue };
|
||||
cmd.Parameters.Add(rc);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
if ((int)rc.Value < 0)
|
||||
throw new TimeoutException($"Could not acquire historian schema applock within {LockTimeoutMs} ms (sp_getapplock returned {rc.Value}).");
|
||||
}
|
||||
|
||||
private async Task ReleaseLockAsync(SqlConnection conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, "sp_releaseapplock");
|
||||
cmd.CommandType = CommandType.StoredProcedure;
|
||||
cmd.Parameters.AddWithValue("@Resource", LockResource);
|
||||
cmd.Parameters.AddWithValue("@LockOwner", "Session");
|
||||
await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Closing the session releases a Session-owned lock anyway.
|
||||
_log.LogDebug(ex, "sp_releaseapplock failed; the lock dies with the connection");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GoLine();
|
||||
|
||||
internal static IEnumerable<string> SplitBatches(string script) =>
|
||||
GoLine().Split(script).Select(b => b.Trim()).Where(b => b.Length > 0);
|
||||
|
||||
private static List<(int Version, string Name, string Text)> LoadScripts()
|
||||
{
|
||||
var asm = typeof(SqlServerSchemaBootstrapper).Assembly;
|
||||
var list = new List<(int, string, string)>();
|
||||
foreach (var res in asm.GetManifestResourceNames())
|
||||
{
|
||||
var m = Regex.Match(res, @"^Schema\.(\d+)_.*\.sql$");
|
||||
if (!m.Success) continue;
|
||||
using var s = asm.GetManifestResourceStream(res)!;
|
||||
using var r = new StreamReader(s);
|
||||
list.Add((int.Parse(m.Groups[1].Value), res, r.ReadToEnd()));
|
||||
}
|
||||
return list.OrderBy(x => x.Item1).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Tag name → <c>TagId</c> resolution shared by writes, reads, provisioning and updates. Caches positive
|
||||
/// results only. Both methods run the schema bootstrap first, so callers need not.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerTagCatalog
|
||||
{
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly ConcurrentDictionary<string, int> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Creates the catalog.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
public SqlServerTagCatalog(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
}
|
||||
|
||||
/// <summary>Read-only lookup; never creates a row.</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The TagId, or null when the tag does not exist.</returns>
|
||||
public async Task<int?> TryGetTagIdAsync(string name, CancellationToken ct)
|
||||
{
|
||||
if (_cache.TryGetValue(name, out var cached)) return cached;
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
var id = await SelectIdAsync(conn, name, ct).ConfigureAwait(false);
|
||||
if (id is { } v) _cache[name] = v;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the TagId, creating the row on a miss. Safe under concurrency (HOLDLOCK MERGE; 2627/2601 re-select).</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
/// <param name="dataType">Initial <c>Tag.DataType</c> (only used on create; provisioning corrects it later).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The TagId.</returns>
|
||||
public async Task<int> GetOrCreateTagIdAsync(string name, byte dataType, CancellationToken ct)
|
||||
{
|
||||
if (_cache.TryGetValue(name, out var cached)) return cached;
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
var sql =
|
||||
$"MERGE {_conn.Q("Tag")} WITH (HOLDLOCK) AS t USING (SELECT @name AS Name) AS s ON t.Name = s.Name " +
|
||||
"WHEN NOT MATCHED THEN INSERT (Name, DataType, CreatedUtc, UpdatedUtc) VALUES (@name, @dt, SYSUTCDATETIME(), SYSUTCDATETIME());";
|
||||
try
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 400) { Value = name });
|
||||
cmd.Parameters.Add(new SqlParameter("@dt", System.Data.SqlDbType.TinyInt) { Value = dataType });
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (SqlException ex) when (SqlHistorianConnection.IsDuplicateKey(ex))
|
||||
{
|
||||
// A concurrent creator won; fall through to the re-select.
|
||||
}
|
||||
var id = await SelectIdAsync(conn, name, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Tag '{name}' vanished after create.");
|
||||
_cache[name] = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Drops a cached id (e.g. after a tag row is deleted).</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
public void Invalidate(string name) => _cache.TryRemove(name, out _);
|
||||
|
||||
private async Task<int?> SelectIdAsync(SqlConnection conn, string name, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, $"SELECT TagId FROM {_conn.Q("Tag")} WHERE Name = @name");
|
||||
cmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 400) { Value = name });
|
||||
var o = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return o is null or DBNull ? null : Convert.ToInt32(o);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<!-- Runtime owns IHistorianBackendProvider / ServerHistorianOptions; same diamond as Driver.Historian.Gateway
|
||||
(Host -> {Runtime, SqlServer}, SqlServer -> Runtime). This project does NOT reference the Gateway project. -->
|
||||
<ProjectReference Include="..\..\Server\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logical name is stable regardless of RootNamespace: "Schema.001_initial.sql". -->
|
||||
<EmbeddedResource Include="Schema\*.sql" LogicalName="Schema.%(Filename)%(Extension)"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
@@ -43,6 +43,7 @@
|
||||
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||
puts it in StatusCodeParityTests' scope. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private SqlServerHistorianOptions Opts(string schema, bool auto = true) => new()
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = schema, AutoCreateSchema = auto,
|
||||
};
|
||||
|
||||
private static SqlServerSchemaBootstrapper Boot(SqlServerHistorianOptions o) =>
|
||||
new(new SqlHistorianConnection(o), o);
|
||||
|
||||
private async Task<long> ScalarAsync(string sql)
|
||||
{
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync();
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrapping_twice_is_a_no_op_with_one_version_row()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_re_run_of_the_raw_script_is_idempotent()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
var script = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync();
|
||||
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(script.Replace("$(Schema)", s)))
|
||||
{
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bootstrappers_both_succeed_with_one_version_row()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoCreateSchema_false_on_an_empty_schema_throws_missing()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None));
|
||||
ex.Message.ShouldContain("missing version 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoCreateSchema_false_passes_once_the_schema_exists_and_failure_is_not_latched()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var checker = Boot(Opts(s, auto: false));
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await checker.EnsureAsync(CancellationToken.None); // same instance retries
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_get_or_create_of_one_new_tag_yields_one_row_and_one_id()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var o = Opts(s);
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
await boot.EnsureAsync(CancellationToken.None);
|
||||
// Separate catalogs => no shared cache, so every task really races the MERGE. Several rounds on fresh
|
||||
// names, all callers released together, raise the odds of hitting the create window.
|
||||
for (var round = 0; round < 10; round++)
|
||||
{
|
||||
var name = $"Plant/Tag{round}";
|
||||
using var start = new ManualResetEventSlim(false);
|
||||
var ids = Enumerable.Range(0, 32).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
var cat = new SqlServerTagCatalog(conn, boot);
|
||||
await cat.TryGetTagIdAsync("warm", CancellationToken.None); // opens the pool + bootstrap before the race
|
||||
start.Wait();
|
||||
return await cat.GetOrCreateTagIdAsync(name, 1, CancellationToken.None);
|
||||
})).ToArray();
|
||||
await Task.Delay(200);
|
||||
start.Set();
|
||||
(await Task.WhenAll(ids)).Distinct().Count().ShouldBe(1);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].Tag WHERE Name = N'{name}'")).ShouldBe(1);
|
||||
}
|
||||
(await new SqlServerTagCatalog(conn, boot).TryGetTagIdAsync("nope", CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sample_tvp_keeps_sub_millisecond_ticks_end_to_end()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var o = Opts(s);
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
var tagId = await new SqlServerTagCatalog(conn, boot).GetOrCreateTagIdAsync("T", 1, CancellationToken.None);
|
||||
var t = new DateTime(2026, 10, 9, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
await using var c = await conn.OpenAsync(CancellationToken.None);
|
||||
await using var cmd = conn.CreateCommand(c,
|
||||
$"INSERT {conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
||||
"SELECT @id, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, SYSUTCDATETIME() FROM @rows");
|
||||
cmd.Parameters.AddWithValue("@id", tagId);
|
||||
conn.AddStructured(cmd, "@rows", "SampleRow",
|
||||
SampleRowMapper.ToSampleRecords([new SqlSampleRow(t, ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistorianValue.FromDouble(1), 0u)]));
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
await using var q = conn.CreateCommand(c, $"SELECT SourceTimeUtc FROM {conn.Q("Sample")}");
|
||||
await using var r = await q.ExecuteReaderAsync();
|
||||
(await r.ReadAsync()).ShouldBeTrue();
|
||||
SqlHistorianConnection.GetUtc(r, 0).Ticks.ShouldBe(t.Ticks);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Env-gated throwaway database for the SQL historian backend. <c>HISTORIAN_SQL_TEST_CONNECTION</c> absent ⇒
|
||||
/// <see cref="SkipReason"/> is set, nothing connects, and every test calls <c>Assert.Skip</c>. Present ⇒
|
||||
/// creates <c>OtOpcUaHistorian_T_<guid></c> (never touches another database) and drops it on dispose.
|
||||
/// Tests isolate from each other with <see cref="UniqueSchema"/>.
|
||||
/// </summary>
|
||||
public sealed class SqlHistorianFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Env var holding a connection string with CREATE/DROP DATABASE rights.</summary>
|
||||
public const string ConnectionEnvVar = "HISTORIAN_SQL_TEST_CONNECTION";
|
||||
|
||||
private string? _master;
|
||||
private string? _dbName;
|
||||
|
||||
/// <summary>Why tests must skip, or null when the database is available.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>Connection string targeting the throwaway database. Valid only when <see cref="SkipReason"/> is null.</summary>
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
|
||||
/// <summary>Skips the calling test when the gate is closed.</summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null) Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
/// <summary>A fresh, valid schema name (nothing exists under it yet).</summary>
|
||||
/// <returns>A schema identifier unique to this call.</returns>
|
||||
public static string UniqueSchema() => "h" + Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable(ConnectionEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
SkipReason = $"{ConnectionEnvVar} is not set; SQL Server historian integration tests skipped.";
|
||||
return;
|
||||
}
|
||||
|
||||
var b = new SqlConnectionStringBuilder(cs) { InitialCatalog = "master" };
|
||||
_master = b.ConnectionString;
|
||||
_dbName = "OtOpcUaHistorian_T_" + Guid.NewGuid().ToString("N");
|
||||
await ExecAsync(_master, $"CREATE DATABASE [{_dbName}]").ConfigureAwait(false);
|
||||
b.InitialCatalog = _dbName;
|
||||
ConnectionString = b.ConnectionString;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_master is null || _dbName is null) return;
|
||||
SqlConnection.ClearAllPools();
|
||||
await ExecAsync(_master,
|
||||
$"ALTER DATABASE [{_dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_dbName}]").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecAsync(string cs, string sql)
|
||||
{
|
||||
await using var c = new SqlConnection(cs);
|
||||
await c.OpenAsync().ConfigureAwait(false);
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shares one throwaway database across the integration tests.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class SqlHistorianCollection : ICollectionFixture<SqlHistorianFixture>
|
||||
{
|
||||
/// <summary>Collection name.</summary>
|
||||
public const string Name = "SqlHistorian";
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Data.SqlClient.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
|
||||
|
||||
public sealed class SampleRowMapperTests
|
||||
{
|
||||
public static TheoryData<HistorianValue> Values => new()
|
||||
{
|
||||
HistorianValue.FromDouble(12.5),
|
||||
HistorianValue.FromInt64(long.MinValue + 3),
|
||||
HistorianValue.FromBoolean(true),
|
||||
HistorianValue.FromBoolean(false),
|
||||
HistorianValue.FromString("héllo"),
|
||||
HistorianValue.FromString(""),
|
||||
HistorianValue.FromDateTime(new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1234567)),
|
||||
HistorianValue.Empty,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Values))]
|
||||
public void Every_kind_round_trips_through_the_column_quad(HistorianValue v)
|
||||
{
|
||||
var c = SampleRowMapper.ToColumns(v);
|
||||
SampleRowMapper.FromColumns(c.ValueKind, c.NumValue, c.IntValue, c.StrValue).ShouldBe(v);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_follow_the_spec_mapping()
|
||||
{
|
||||
SampleRowMapper.ToColumns(HistorianValue.FromInt64(7)).ShouldBe(new SampleColumns(2, 7d, 7L, null));
|
||||
SampleRowMapper.ToColumns(HistorianValue.FromBoolean(true)).ShouldBe(new SampleColumns(3, 1d, 1L, null));
|
||||
SampleRowMapper.ToColumns(HistorianValue.FromDouble(1.5)).ShouldBe(new SampleColumns(1, 1.5, null, null));
|
||||
SampleRowMapper.ToColumns(HistorianValue.Empty).ShouldBe(new SampleColumns(0, null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_is_stored_bit_for_bit()
|
||||
{
|
||||
SampleRowMapper.ToDbStatus(0x80050000u).ShouldBeLessThan(0);
|
||||
SampleRowMapper.FromDbStatus(SampleRowMapper.ToDbStatus(0x80050000u)).ShouldBe(0x80050000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_records_keep_sub_millisecond_ticks()
|
||||
{
|
||||
var t = new DateTime(2026, 10, 9, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
var rec = SampleRowMapper.ToSampleRecords([new SqlSampleRow(t, HistorianValue.FromDouble(2), 0x80050000u)]).Single();
|
||||
rec.GetDateTime(0).Ticks.ShouldBe(t.Ticks);
|
||||
rec.GetByte(1).ShouldBe((byte)1);
|
||||
rec.GetDouble(2).ShouldBe(2d);
|
||||
rec.IsDBNull(3).ShouldBeTrue();
|
||||
rec.IsDBNull(4).ShouldBeTrue();
|
||||
rec.GetInt32(5).ShouldBe(unchecked((int)0x80050000u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Time_records_keep_sub_millisecond_ticks()
|
||||
{
|
||||
var t = new DateTime(2026, 10, 9, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
SampleRowMapper.ToTimeRecords([t]).Single().GetDateTime(0).Ticks.ShouldBe(t.Ticks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
|
||||
|
||||
public sealed class SchemaScriptTests
|
||||
{
|
||||
[Fact]
|
||||
public void Go_splitting_ignores_go_inside_other_text_and_drops_empties()
|
||||
{
|
||||
var parts = SqlServerSchemaBootstrapper.SplitBatches("A\nGO\n go \nB GO C\nGO\n").ToArray();
|
||||
parts.ShouldBe(["A", "B GO C"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Embedded_script_is_present_and_matches_the_docs_copy()
|
||||
{
|
||||
var asm = typeof(SqlServerSchemaBootstrapper).Assembly;
|
||||
using var s = asm.GetManifestResourceStream("Schema.001_initial.sql");
|
||||
s.ShouldNotBeNull();
|
||||
var text = new StreamReader(s!).ReadToEnd();
|
||||
text.ShouldContain("$(Schema)");
|
||||
text.ShouldContain("PRIMARY KEY NONCLUSTERED");
|
||||
text.ShouldContain("CREATE CLUSTERED INDEX CX_Event_Time");
|
||||
text.ShouldNotContain("IX_Event_Time");
|
||||
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
|
||||
dir.ShouldNotBeNull();
|
||||
File.ReadAllText(Path.Combine(dir!.FullName, "docs/historian/sqlserver/001_initial.sql")).ShouldBe(text);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
|
||||
|
||||
public sealed class SqlServerHistorianOptionsTests
|
||||
{
|
||||
private static SqlServerHistorianOptions Valid() => new() { ConnectionString = "Server=x" };
|
||||
|
||||
[Fact]
|
||||
public void Defaults_with_a_connection_string_are_valid() => Valid().Validate().ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Missing_connection_string_is_rejected() =>
|
||||
new SqlServerHistorianOptions().Validate().ShouldContain(e => e.Contains("ConnectionString"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("hist]; DROP TABLE x;--")]
|
||||
[InlineData("1abc")]
|
||||
[InlineData("a b")]
|
||||
[InlineData("")]
|
||||
[InlineData("a.b")]
|
||||
public void Invalid_schema_names_are_rejected_before_any_sql_runs(string schema)
|
||||
{
|
||||
var o = Valid();
|
||||
o.Schema = schema;
|
||||
o.Validate().ShouldContain(e => e.Contains("Schema"));
|
||||
Should.Throw<ArgumentException>(() => new SqlHistorianConnection(o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_positive_timeout_is_rejected()
|
||||
{
|
||||
var o = Valid();
|
||||
o.CommandTimeout = TimeSpan.Zero;
|
||||
o.Validate().ShouldContain(e => e.Contains("CommandTimeout"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Q_schema_qualifies_and_brackets()
|
||||
{
|
||||
var o = Valid();
|
||||
o.Schema = "my_s";
|
||||
new SqlHistorianConnection(o).Q("Tag").ShouldBe("[my_s].[Tag]");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
|
||||
|
||||
/// <summary>The pinned SDK is the oracle for the info-bit constants (StatusCodeParityTests covers the named codes).</summary>
|
||||
public sealed class StatusCodeOracleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calculated_bits_match_the_SDK()
|
||||
{
|
||||
var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Calculated);
|
||||
sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(SqlHistorianStatusCodes.Good));
|
||||
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interpolated_bits_match_the_SDK()
|
||||
{
|
||||
var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Interpolated);
|
||||
sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.Good));
|
||||
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Info_type_is_data_value() =>
|
||||
HistorianInfoBits.InfoTypeDataValue.ShouldBe(0x400u);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- SDK oracle for the status-code / aggregate-bit tests. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user