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
@@ -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();
}
}