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:
Joseph Doherty
2026-10-09 12:52:15 -04:00
parent ff7c6c531a
commit 17962d4233
19 changed files with 1187 additions and 0 deletions
+3
View File
@@ -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" />
+69
View File
@@ -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) { }
}
@@ -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) &gt;= 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);
}
}
@@ -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>
@@ -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>
@@ -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);
}
}
@@ -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_&lt;guid&gt;</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";
}
@@ -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>
@@ -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);
}
}
@@ -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]");
}
}
@@ -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);
}
@@ -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>