refactor(historian): extract Driver.Historian.Sql.Common from the SQL Server backend (no behaviour change) (1a)

Moves the database-independent logic of the SQL Server historian backend into a new
shared library for the upcoming QuestDB backend (#666): status codes and bits, value/column
mapping, StoredSample + quality clamp, the AtTime and aggregate evaluators, BucketPlan,
LiveWritePreparer and HistoryUpdatePlanner (both with a resolutionTicks parameter, default 1),
tag-name rules, the bootstrap failure cache, health counters, alarm event ids, the event
continuation and severity clamp. The SQL Server backend now calls it; its T-SQL, TVP
builders, connection, bootstrapper and catalog stay.

Tests: StatusCodeOracleTests, ValueWriterPrepareTests, the column half of SampleRowMapperTests
and ProcessedReadTests.Every_enum_member_is_either_served_or_refused move to the new
Sql.Common.Tests under the same names. SqlServer integration tests get mechanical renames
only. Test-name set across the three projects is identical to the baseline (160 tests).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-11 01:26:49 -04:00
parent cdea173bd1
commit 8c6d6aff6a
49 changed files with 1262 additions and 672 deletions
@@ -0,0 +1,16 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>The DB-free part of the former SQL Server integration <c>ProcessedReadTests</c>.</summary>
public sealed class ProcessedReadTests
{
[Fact]
public void Every_enum_member_is_either_served_or_refused()
{
// Thirteen served; anything appended to the enum later falls to the refusal (total default).
Enum.GetValues<HistoryAggregateType>().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(13);
}
}
@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>The column round-trip half of the former SQL Server <c>SampleRowMapperTests</c> (the TVP half stays there).</summary>
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 = SampleColumnMapper.ToColumns(v);
SampleColumnMapper.FromColumns(c.ValueKind, c.NumValue, c.IntValue, c.StrValue).ShouldBe(v);
}
[Fact]
public void Columns_follow_the_spec_mapping()
{
SampleColumnMapper.ToColumns(HistorianValue.FromInt64(7)).ShouldBe(new SampleColumns(2, 7d, 7L, null));
SampleColumnMapper.ToColumns(HistorianValue.FromBoolean(true)).ShouldBe(new SampleColumns(3, 1d, 1L, null));
SampleColumnMapper.ToColumns(HistorianValue.FromDouble(1.5)).ShouldBe(new SampleColumns(1, 1.5, null, null));
SampleColumnMapper.ToColumns(HistorianValue.Empty).ShouldBe(new SampleColumns(0, null, null, null));
}
[Fact]
public void Status_is_stored_bit_for_bit()
{
SampleColumnMapper.ToDbStatus(0x80050000u).ShouldBeLessThan(0);
SampleColumnMapper.FromDbStatus(SampleColumnMapper.ToDbStatus(0x80050000u)).ShouldBe(0x80050000u);
}
}
@@ -1,7 +1,7 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>The pinned SDK is the oracle for the info-bit constants (StatusCodeParityTests covers the named codes).</summary>
public sealed class StatusCodeOracleTests
@@ -10,7 +10,7 @@ public sealed class StatusCodeOracleTests
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));
sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(HistorianStatusCodes.Good));
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
}
@@ -18,7 +18,7 @@ public sealed class StatusCodeOracleTests
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));
sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(HistorianStatusCodes.Good));
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
}
@@ -30,9 +30,9 @@ public sealed class StatusCodeOracleTests
[Fact]
public void Setting_one_historian_bit_clears_the_other()
{
var calculatedUncertain = SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated;
var calculatedUncertain = HistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated;
var interpolated = HistorianInfoBits.WithInterpolated(calculatedUncertain);
interpolated.ShouldBe(SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated);
interpolated.ShouldBe(HistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated);
new Opc.Ua.StatusCode(interpolated).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
var interpolatedGood = 0x400u | HistorianInfoBits.HistorianInterpolated;
@@ -2,7 +2,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
public sealed class ValueWriterPrepareTests
{
@@ -13,7 +13,7 @@ public sealed class ValueWriterPrepareTests
{
const uint uncertain = 0x40000000u;
const uint badNoCommunication = 0x80310000u;
var rows = SqlServerHistorianValueWriter.Prepare(
var rows = LiveWritePreparer.Prepare(
[
new(T, HistorianValue.FromDouble(double.NaN), uncertain),
new(T.AddTicks(1), HistorianValue.FromDouble(double.PositiveInfinity), 0),
@@ -21,26 +21,26 @@ public sealed class ValueWriterPrepareTests
new(T.AddTicks(3), HistorianValue.FromDouble(1.5), 0),
], () => T);
rows.Select(r => r.Value.Kind).ShouldBe([HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Double]);
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadOutOfRange, SqlHistorianStatusCodes.BadOutOfRange,
rows.Select(r => r.StatusCode).ShouldBe([HistorianStatusCodes.BadOutOfRange, HistorianStatusCodes.BadOutOfRange,
badNoCommunication, 0u]);
}
[Fact]
public void A_non_bad_Empty_is_stored_BadNoData_and_a_bad_Empty_keeps_its_code()
{
var rows = SqlServerHistorianValueWriter.Prepare(
var rows = LiveWritePreparer.Prepare(
[
new(T, HistorianValue.Empty, 0),
new(T.AddTicks(1), HistorianValue.Empty, 0x40A40000u),
new(T.AddTicks(2), HistorianValue.Empty, 0x80310000u),
], () => T);
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadNoData, SqlHistorianStatusCodes.BadNoData, 0x80310000u]);
rows.Select(r => r.StatusCode).ShouldBe([HistorianStatusCodes.BadNoData, HistorianStatusCodes.BadNoData, 0x80310000u]);
}
[Fact]
public void Duplicate_timestamps_keep_the_first_and_null_timestamp_uses_the_clock()
{
var rows = SqlServerHistorianValueWriter.Prepare(
var rows = LiveWritePreparer.Prepare(
[
new(T, HistorianValue.FromInt64(1), 0), new(T, HistorianValue.FromInt64(2), 0),
new(null, HistorianValue.FromInt64(3), 0),
@@ -53,8 +53,8 @@ public sealed class ValueWriterPrepareTests
[Fact]
public void Data_type_mapping_is_one_table()
{
SqlHistorianDataTypes.FromKind(HistorianValueKind.Double).ShouldBe((byte)DriverDataType.Float64);
SqlHistorianDataTypes.FromKind(HistorianValueKind.Boolean).ShouldBe((byte)DriverDataType.Boolean);
SqlHistorianDataTypes.FromKind(HistorianValueKind.Empty).ShouldBe((byte)DriverDataType.Float64);
HistorianDataTypes.FromKind(HistorianValueKind.Double).ShouldBe((byte)DriverDataType.Float64);
HistorianDataTypes.FromKind(HistorianValueKind.Boolean).ShouldBe((byte)DriverDataType.Boolean);
HistorianDataTypes.FromKind(HistorianValueKind.Empty).ShouldBe((byte)DriverDataType.Float64);
}
}
@@ -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.Sql.Common.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.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
</ItemGroup>
</Project>
@@ -1,6 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
@@ -360,13 +360,6 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx)
src.GetHealthSnapshot().TotalQueries.ShouldBe(0);
}
[Fact]
public void Every_enum_member_is_either_served_or_refused()
{
// Thirteen served; anything appended to the enum later falls to the refusal (total default).
Enum.GetValues<HistoryAggregateType>().Count(a => SqlAggregateQueries.FamilyOf(a) is not null).ShouldBe(13);
}
[Fact]
public async Task The_bucket_count_is_capped_at_100000()
{
@@ -1,6 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
@@ -96,8 +97,8 @@ public sealed class QualityContractTests(SqlHistorianFixture fx)
new(At(2), HistorianValue.Empty, BadNoCommunication),
new(At(3), HistorianValue.FromDouble(1), 0u),
], Ct);
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted,
SqlHistorianStatusCodes.GoodEntryInserted]);
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.GoodEntryInserted]);
(await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
.Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]);
}
@@ -2,6 +2,7 @@ using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
@@ -59,7 +60,7 @@ internal sealed class ReadTestDb
}
foreach (var (t, v, status) in samples)
{
var col = SampleRowMapper.ToColumns(v);
var col = SampleColumnMapper.ToColumns(v);
await using var cmd = Conn.CreateCommand(c,
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
@@ -69,7 +70,7 @@ internal sealed class ReadTestDb
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
cmd.Parameters.AddWithValue("@st", SampleRowMapper.ToDbStatus(status));
cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status));
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
await cmd.ExecuteNonQueryAsync();
}
@@ -253,7 +254,7 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
// Another node creates the schema; the same data source then succeeds (bootstrap failure is not latched).
var creator = new SqlServerHistorianOptions { ConnectionString = o.ConnectionString, Schema = o.Schema };
await new SqlServerSchemaBootstrapper(new SqlHistorianConnection(creator), creator).EnsureAsync(Ct);
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration; // past the 5 s failure cache (M-2)
clock.Now += BootstrapFailureCache.DefaultDuration; // past the 5 s failure cache (M-2)
(await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty();
src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0);
}
@@ -1,6 +1,7 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
@@ -82,12 +83,12 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock);
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration;
clock.Now += BootstrapFailureCache.DefaultDuration;
await checker.EnsureAsync(CancellationToken.None); // same instance retries once the failure cache expires
}
/// <summary>
/// M-2: within <see cref="SqlServerSchemaBootstrapper.FailureCacheDuration"/> of a failure the bootstrapper
/// M-2: within <see cref="BootstrapFailureCache.DefaultDuration"/> of a failure the bootstrapper
/// re-throws it without touching SQL — proven by creating the schema in between: a call that went to SQL
/// would now pass.
/// </summary>
@@ -103,7 +104,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // the schema now exists
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration - TimeSpan.FromTicks(1);
clock.Now += BootstrapFailureCache.DefaultDuration - TimeSpan.FromTicks(1);
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
clock.Now += TimeSpan.FromTicks(1);
@@ -156,7 +157,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
"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)]));
SampleRowMapper.ToSampleRecords([new HistorianSampleRow(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();
@@ -2,14 +2,15 @@ using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
[Collection(SqlHistorianCollection.Name)]
public sealed class UpdateTests(SqlHistorianFixture fx)
{
private const uint Good = SqlHistorianStatusCodes.Good;
private const uint UncertainStatus = SqlHistorianStatusCodes.UncertainDataSubNormal;
private const uint Good = HistorianStatusCodes.Good;
private const uint UncertainStatus = HistorianStatusCodes.UncertainDataSubNormal;
// Deliberately NOT millisecond aligned: sub-ms ticks must survive the TVP, MERGE join and DELETE join.
private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
@@ -58,10 +59,10 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
{
var (u, s) = Make();
var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
r1.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]);
r1.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
// Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose.
var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct);
r2.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists, SqlHistorianStatusCodes.BadEntryExists]);
r2.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.BadEntryExists]);
var rows = await RowsAsync(s, "t");
rows.Select(x => x.Num).ShouldBe([1d, 2d, 3d]); // existing values untouched
rows.Select(x => x.Time).ShouldBe([T(1), T(2), T(3)]); // sub-ms ticks preserved
@@ -74,7 +75,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct);
r.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.BadNoEntryExists, SqlHistorianStatusCodes.GoodEntryReplaced]);
r.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.GoodEntryReplaced]);
var rows = await RowsAsync(s, "t");
rows.Count.ShouldBe(2); // T(5) was not created
rows[0].Num.ShouldBe(11d);
@@ -89,7 +90,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct);
r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryReplaced]);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]);
(await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([10d, 2d]);
}
@@ -98,13 +99,13 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
{
var (u, s) = Make();
(await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct))
.ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]);
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await TagCountAsync(s)).ShouldBe(0); // none of those created a tag
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await TagCountAsync(s)).ShouldBe(2);
}
@@ -114,13 +115,13 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct);
r.ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadInvalidTimestamp]);
r.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidTimestamp]);
(await RowsAsync(s, "t")).Count.ShouldBe(1);
// All-invalid request: no DB work, no tag created, still one code per item.
var (u2, s2) = Make();
await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp]);
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
(await TagCountAsync(s2)).ShouldBe(1); // only "seed"; "x" was never created
}
@@ -130,8 +131,8 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct);
r.ShouldBe([SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted,
SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianUpdateStatusCodes.BadInvalidArgument]);
r.ShouldBe([HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument]);
(await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([2d]);
}
@@ -140,7 +141,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
{
var (u, s) = Make();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct);
r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists]);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]);
(await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([1d, 2d]);
}
@@ -150,11 +151,11 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct);
rep.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.GoodEntryReplaced]);
rep.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced]);
(await RowsAsync(s, "t")).Single().Num.ShouldBe(6d);
var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct);
upd.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]);
upd.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
(await RowsAsync(s, "t")).Last().Num.ShouldBe(8d);
}
@@ -164,7 +165,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced]);
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
var rows = await RowsAsync(s, "t");
rows.Single().Num.ShouldBeNull(); // NumValue cleared by the String replace
}
@@ -177,9 +178,9 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
// [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives.
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good);
(await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(3)]);
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
// sub-ms boundary: a start one tick after T(3) excludes it.
(await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await RowsAsync(s, "t")).Count.ShouldBe(1);
}
@@ -189,7 +190,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
var (u, s) = Make();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct);
var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(1) /* near miss */, T(1), DateTime.MinValue], Ct);
r.ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists, Good, SqlHistorianStatusCodes.BadInvalidTimestamp]);
r.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, Good, HistorianStatusCodes.BadInvalidTimestamp]);
(await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(2)]);
}
@@ -198,7 +199,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx)
{
var (u, s) = Make();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]);
(await RowsAsync(s, "t")).ShouldBeEmpty();
}
}
@@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
@@ -55,11 +56,11 @@ public sealed class WriteTests(SqlHistorianFixture fx)
var rows = await RowsAsync(s, "tag.a");
rows.Count.ShouldBe(6);
for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(vals[i].TimestampUtc!.Value.Ticks);
var back = rows.Select(r => SampleRowMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList();
var back = rows.Select(r => SampleColumnMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList();
back.ShouldBe(vals.Select(v => v.Value).ToList());
(rows[5].N, rows[5].I, rows[5].S).ShouldBe((null, null, null));
rows[1].Status.ShouldBe(unchecked((int)0x40000000u));
SampleRowMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u);
SampleColumnMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u);
}
[Fact]
@@ -101,8 +102,8 @@ public sealed class WriteTests(SqlHistorianFixture fx)
rows.Count.ShouldBe(3);
rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty);
rows[0].N.ShouldBeNull();
rows[0].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1)
rows[1].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1)
rows[0].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1)
rows[1].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1)
rows[2].N.ShouldBe(7);
}
@@ -2,6 +2,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
@@ -16,8 +17,8 @@ public sealed class AlarmEventIdTests
[Fact]
public void Id_is_stable_lowercase_hex_sha256()
{
var a = SqlServerAlarmHistorianWriter.ComputeEventId(Ev());
a.ShouldBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev()));
var a = AlarmEventIds.ComputeEventId(Ev());
a.ShouldBe(AlarmEventIds.ComputeEventId(Ev()));
a.Length.ShouldBe(64);
a.ShouldBe(a.ToLowerInvariant());
}
@@ -26,23 +27,23 @@ public sealed class AlarmEventIdTests
public void Field_boundaries_are_unambiguous()
{
// The delimiter-based scheme collided on these; length prefixes must not.
SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a|b", id: "c"))
.ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a", id: "b|c")));
SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "ab", id: "c"))
.ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a", id: "bc")));
AlarmEventIds.ComputeEventId(Ev(path: "a|b", id: "c"))
.ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(path: "a", id: "b|c")));
AlarmEventIds.ComputeEventId(Ev(path: "ab", id: "c"))
.ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(path: "a", id: "bc")));
}
[Fact]
public void Null_and_empty_differ_and_every_field_participates()
{
var basis = SqlServerAlarmHistorianWriter.ComputeEventId(Ev());
SqlServerAlarmHistorianWriter.ComputeEventId(Ev(comment: null)).ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(comment: "")));
var basis = AlarmEventIds.ComputeEventId(Ev());
AlarmEventIds.ComputeEventId(Ev(comment: null)).ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(comment: "")));
foreach (var other in new[]
{
Ev(path: "Q"), Ev(id: "B"), Ev(kind: "Cleared"), Ev(user: "v"), Ev(comment: "d"), Ev(msg: "x"),
Ev(t: T.AddTicks(1)),
})
SqlServerAlarmHistorianWriter.ComputeEventId(other).ShouldNotBe(basis);
AlarmEventIds.ComputeEventId(other).ShouldNotBe(basis);
}
[Theory]
@@ -2,52 +2,18 @@ using Microsoft.Data.SqlClient.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
/// <summary>The TVP half of the mapper tests (the column round-trip half moved to Sql.Common.Tests).</summary>
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();
var rec = SampleRowMapper.ToSampleRecords([new HistorianSampleRow(t, HistorianValue.FromDouble(2), 0x80050000u)]).Single();
rec.GetDateTime(0).Ticks.ShouldBe(t.Ticks);
rec.GetByte(1).ShouldBe((byte)1);
rec.GetDouble(2).ShouldBe(2d);