feat(historian): SQL Server backend skeleton - schema DDL, locked bootstrapper, options, connection/mapper/tag-catalog helpers, status consts, env-gated fixture
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.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.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.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/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.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" />
|
<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.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.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.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.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.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" />
|
<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
|
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||||
puts it in StatusCodeParityTests' scope. -->
|
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.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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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