feat(historian): SQL Server read side — raw, at-time, events, push-down processed (ph-T10)

SqlServerHistorianDataSource implements IHistorianDataSource over the T8 schema:
raw forward/reverse/point with the 0 = unbounded sentinel; AtTime in one statement
(TimeRow TVP + two OUTER APPLYs) with linear/stepped/no-prior rules and no
interpolation across a Bad neighbour or Empty outage marker; events with a
maxEvents+1 truncation probe and a non-null continuation point; processed
aggregates pushed down to T-SQL (SqlAggregateQueries) with nanosecond-exact
bucket edges, Calculated bits on every result, Uncertain on any non-Good input,
BadAggregateInvalidInputs for numeric aggregates over String/DateTime, a
100 000-bucket cap and HistoryAggregateNotSupportedException for the rest.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 13:13:07 -04:00
parent 65e6352dad
commit 38d37f5c23
6 changed files with 1553 additions and 0 deletions
@@ -0,0 +1,184 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>AtTime rules (spec §4): exact, linear, stepped, no-prior, Bad neighbour (no bridging).</summary>
[Collection(SqlHistorianCollection.Name)]
public sealed class AtTimeReadTests(SqlHistorianFixture fx)
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static DateTime At(double s) => ReadTestDb.At(s);
// Hand-computed status literals: Interpolated = InfoType DataValue 0x400 | Interpolated 0x2.
private const uint GoodInterpolated = 0x00000402u;
private const uint UncertainInterpolated = 0x40A40402u; // UncertainDataSubNormal | 0x402
private const uint BadNoData = 0x809B0000u;
[Fact]
public async Task Interpolated_bits_are_the_SDK_AggregateBits_Interpolated()
{
var code = new Opc.Ua.StatusCode(GoodInterpolated);
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
await Task.CompletedTask;
}
[Fact]
public async Task Exact_hit_returns_the_stored_value_and_status()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples;
s[0].Value.ShouldBe(20d);
s[0].StatusCode.ShouldBe(0u);
s[0].SourceTimestampUtc.ShouldBe(At(10));
s[0].ServerTimestampUtc.ShouldBe(At(11)); // the stored ServerTimeUtc
s[1].Value.ShouldBe(30d); // a stored Bad sample comes back as stored
s[1].StatusCode.ShouldBe(0x80000000u);
}
[Fact]
public async Task Between_two_good_doubles_is_linearly_interpolated_good()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(2.5), At(32.5)], Ct)).Samples;
s[0].Value.ShouldBe(15d); // 10 + (20-10) * 5/10
s[1].Value.ShouldBe(12.5d); // 10 + 10 * 2.5/10
s[2].Value.ShouldBe(42.5d); // 40 + 10 * 2.5/10
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
s[0].SourceTimestampUtc.ShouldBe(At(5)); // stamped at the requested (sub-ms) time
}
[Fact]
public async Task No_previous_sample_is_BadNoData()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(0).AddTicks(-1)], Ct)).Samples.Single();
s.Value.ShouldBeNull();
s.StatusCode.ShouldBe(BadNoData);
s.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1));
}
[Fact]
public async Task A_bad_next_neighbour_holds_the_previous_value_uncertain()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(15)], Ct)).Samples.Single();
s.Value.ShouldBe(20d); // NOT 25 (interpolating toward the Bad 30)
s.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public async Task A_bad_previous_neighbour_yields_no_value_uncertain()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single();
s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value)
s.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public async Task Interpolation_never_bridges_an_outage_marker()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("M",
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
(At(10), HistorianValue.FromDouble(30), ReadTestDb.Good));
var s = (await db.Source().ReadAtTimeAsync("M", [At(2.5), At(7.5), At(5)], Ct)).Samples;
s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15
s[0].StatusCode.ShouldBe(UncertainInterpolated);
s[1].Value.ShouldBeNull(); // bridging would give 25
s[1].StatusCode.ShouldBe(UncertainInterpolated);
s[2].Value.ShouldBeNull(); // exact hit on the marker: as stored
s[2].StatusCode.ShouldBe(0x80310000u);
}
[Fact]
public async Task After_the_last_sample_the_value_is_stepped()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single();
s.Value.ShouldBe(50d);
s.StatusCode.ShouldBe(GoodInterpolated);
}
[Fact]
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("U",
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Uncertain),
(At(10), HistorianValue.FromDouble(20), ReadTestDb.Good));
var s = (await db.Source().ReadAtTimeAsync("U", [At(5)], Ct)).Samples.Single();
s.Value.ShouldBe(15d);
s.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public async Task Int64_is_interpolated_and_rounded_keeping_its_type()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("I",
(At(0), HistorianValue.FromInt64(10), ReadTestDb.Good),
(At(10), HistorianValue.FromInt64(15), ReadTestDb.Good));
var s = (await db.Source().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples;
s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero)
s[1].Value.ShouldBe(11L); // 11.0
s[0].StatusCode.ShouldBe(GoodInterpolated);
}
[Fact]
public async Task Boolean_string_and_datetime_are_stepped_from_the_previous_sample()
{
var db = await ReadTestDb.CreateAsync(fx);
var d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3);
await db.SeedAsync("B",
(At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good),
(At(10), HistorianValue.FromBoolean(false), ReadTestDb.Good));
await db.SeedAsync("S",
(At(0), HistorianValue.FromString("alpha"), ReadTestDb.Uncertain),
(At(10), HistorianValue.FromString("beta"), ReadTestDb.Good));
await db.SeedAsync("D",
(At(0), HistorianValue.FromDateTime(d0), ReadTestDb.Good),
(At(10), HistorianValue.FromDateTime(d0.AddDays(1)), ReadTestDb.Good));
var src = db.Source();
var b = (await src.ReadAtTimeAsync("B", [At(9)], Ct)).Samples.Single();
b.Value.ShouldBe(true);
b.StatusCode.ShouldBe(GoodInterpolated);
var s = (await src.ReadAtTimeAsync("S", [At(5)], Ct)).Samples.Single();
s.Value.ShouldBe("alpha");
s.StatusCode.ShouldBe(0x40000402u); // the previous sample's code (Uncertain) + Interpolated
(await src.ReadAtTimeAsync("D", [At(5)], Ct)).Samples.Single().Value.ShouldBe(d0);
}
[Fact]
public async Task Result_matches_request_length_and_order_including_duplicates()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(-1), At(5), At(10)], Ct)).Samples;
s.Count.ShouldBe(4);
s.Select(x => x.Value).ShouldBe([15d, null, 15d, 20d]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(5), At(-1), At(5), At(10)]);
}
[Fact]
public async Task Empty_request_and_unknown_tag()
{
var db = await ReadTestDb.CreateAsync(fx);
var src = db.Source();
(await src.ReadAtTimeAsync("A", [], Ct)).Samples.ShouldBeEmpty();
var s = (await src.ReadAtTimeAsync("nope", [At(1), At(2)], Ct)).Samples;
s.Count.ShouldBe(2);
s.ShouldAllBe(x => x.StatusCode == BadNoData && x.Value == null);
}
}
@@ -0,0 +1,92 @@
using System.Text;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
[Collection(SqlHistorianCollection.Name)]
public sealed class EventReadTests(SqlHistorianFixture fx)
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static DateTime At(double s) => ReadTestDb.At(s);
private static async Task InsertEventAsync(ReadTestDb db, string id, string source, DateTime t, string? message, int severity)
{
await using var c = await db.Conn.OpenAsync(Ct);
await using var cmd = db.Conn.CreateCommand(c,
$"INSERT {db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES (@id, @src, 'a', 'n', 'T', @sev, 'Activated', @msg, NULL, NULL, @t, @r)");
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@src", source);
cmd.Parameters.AddWithValue("@sev", severity);
cmd.Parameters.AddWithValue("@msg", (object?)message ?? DBNull.Value);
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
SqlHistorianConnection.AddDateTime2(cmd, "@r", t.AddTicks(3));
await cmd.ExecuteNonQueryAsync();
}
private static async Task<ReadTestDb> SeedAsync(SqlHistorianFixture fx)
{
var db = await ReadTestDb.CreateAsync(fx);
await InsertEventAsync(db, "e1", "Eq/1", At(0), "first", 500);
await InsertEventAsync(db, "e2", "Eq/2", At(1), null, 700);
await InsertEventAsync(db, "e3", "Eq/1", At(2), "third", 900);
await InsertEventAsync(db, "e4", "Eq/1", At(3), "at end", 100);
return db;
}
[Fact]
public async Task Source_filter_window_order_and_field_mapping()
{
var db = await SeedAsync(fx);
var r = await db.Source().ReadEventsAsync("Eq/1", At(0), At(3), 0, Ct);
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e3"]); // e4 at end excluded, e2 other source
var e = r.Events[0];
e.SourceName.ShouldBe("Eq/1");
e.EventTimeUtc.ShouldBe(At(0));
e.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
e.ReceivedTimeUtc.ShouldBe(At(0).AddTicks(3));
e.Message.ShouldBe("first");
e.Severity.ShouldBe((ushort)500);
r.ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task Null_source_returns_every_source_and_null_messages()
{
var db = await SeedAsync(fx);
var r = await db.Source().ReadEventsAsync(null, At(0), At(10), 0, Ct);
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e2", "e3", "e4"]);
r.Events[1].Message.ShouldBeNull();
}
[Fact]
public async Task Truncation_drops_the_extra_row_and_returns_a_continuation_point()
{
var db = await SeedAsync(fx);
var r = await db.Source().ReadEventsAsync(null, At(0), At(3), 2, Ct); // 3 matching rows
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e2"]);
r.ContinuationPoint.ShouldNotBeNull();
Encoding.UTF8.GetString(r.ContinuationPoint!).ShouldBe($"{At(2).Ticks}|e3");
}
[Fact]
public async Task An_exact_fit_or_an_unbounded_read_has_no_continuation_point()
{
var db = await SeedAsync(fx);
var src = db.Source();
var exact = await src.ReadEventsAsync(null, At(0), At(3), 3, Ct);
exact.Events.Count.ShouldBe(3);
exact.ContinuationPoint.ShouldBeNull();
(await src.ReadEventsAsync(null, At(0), At(10), int.MaxValue, Ct)).Events.Count.ShouldBe(4); // node manager's "no limit"
(await src.ReadEventsAsync(null, At(0), At(10), -1, Ct)).ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task A_reversed_window_is_swapped()
{
var db = await SeedAsync(fx);
(await db.Source().ReadEventsAsync(null, At(3), At(0), 0, Ct)).Events.Select(e => e.EventId)
.ShouldBe(["e1", "e2", "e3"]);
}
}
@@ -0,0 +1,337 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// Push-down aggregates over tag A (+0=10, +10=20, +20=Bad(30), +30=40, +40=50), 20 s intervals from the
/// sub-millisecond <see cref="ReadTestDb.T0"/>, window [t0, t0+80s) ⇒ four buckets:
/// B0 [0,20) = {10, 20}; B1 [20,40) = {Bad 30, 40}; B2 [40,60) = {50}; B3 [60,80) = {} (empty).
/// Every expected value below is hand-computed.
/// </summary>
[Collection(SqlHistorianCollection.Name)]
public sealed class ProcessedReadTests(SqlHistorianFixture fx)
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20);
private static DateTime At(double s) => ReadTestDb.At(s);
// Calculated = InfoType DataValue 0x400 | Calculated 0x1, OR-ed onto the code.
private const uint GoodCalc = 0x00000401u;
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal | 0x401
private const uint NoDataCalc = 0x809B0401u; // BadNoData | 0x401
private const uint InvalidInputsCalc = 0x80D60401u; // BadAggregateInvalidInputs | 0x401
private static async Task<HistoryReadResult> ReadA(ReadTestDb db, HistoryAggregateType agg) =>
await db.Source().ReadProcessedAsync("A", At(0), At(80), Twenty, agg, Ct);
[Fact]
public void The_status_literals_carry_the_SDK_Calculated_aggregate_bits()
{
new Opc.Ua.StatusCode(GoodCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
new Opc.Ua.StatusCode(UncertainCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
new Opc.Ua.StatusCode(UncertainCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
new Opc.Ua.StatusCode(NoDataCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadNoData);
new Opc.Ua.StatusCode(NoDataCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
new Opc.Ua.StatusCode(InvalidInputsCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadAggregateInvalidInputs);
}
[Theory]
[InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)]
[InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)]
[InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)]
[InlineData(HistoryAggregateType.Total, 30d, 40d, 50d)]
[InlineData(HistoryAggregateType.Start, 10d, 40d, 50d)]
[InlineData(HistoryAggregateType.End, 20d, 40d, 50d)]
[InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)]
[InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)]
public async Task Numeric_aggregates_over_the_fixture(HistoryAggregateType agg, double b0, double b1, double b2)
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await ReadA(db, agg)).Samples;
s.Count.ShouldBe(4);
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
// B1 holds a Bad sample alongside a usable one ⇒ Uncertain; the empty B3 ⇒ BadNoData.
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); // bucket starts
}
[Fact]
public async Task Count_counts_usable_samples_as_int()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await ReadA(db, HistoryAggregateType.Count)).Samples;
s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
}
[Theory]
[InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)]
[InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)]
public async Task Percent_aggregates_count_every_sample(HistoryAggregateType agg, double b0, double b1, double b2)
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await ReadA(db, agg)).Samples;
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
// A percentage is a statement about quality, so it is not itself degraded by the Bad input.
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]);
}
[Fact]
public async Task Actual_time_aggregates_stamp_the_sample_time()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var min = (await ReadA(db, HistoryAggregateType.MinimumActualTime)).Samples;
min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]);
min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]);
min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
var max = (await ReadA(db, HistoryAggregateType.MaximumActualTime)).Samples;
max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]);
max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]);
}
[Fact]
public async Task Actual_time_ties_resolve_to_the_earliest_sample()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("T",
(At(0), HistorianValue.FromDouble(5), ReadTestDb.Good),
(At(2), HistorianValue.FromDouble(3), ReadTestDb.Good),
(At(4), HistorianValue.FromDouble(3), ReadTestDb.Good),
(At(6), HistorianValue.FromDouble(7), ReadTestDb.Good),
(At(8), HistorianValue.FromDouble(7), ReadTestDb.Good));
var src = db.Source();
var min = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MinimumActualTime, Ct)).Samples.Single();
min.Value.ShouldBe(3d);
min.SourceTimestampUtc.ShouldBe(At(2));
var max = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
max.Value.ShouldBe(7d);
max.SourceTimestampUtc.ShouldBe(At(6));
}
[Fact]
public async Task Bucket_edges_are_start_inclusive_end_exclusive_to_the_tick()
{
// T0 is not millisecond-aligned: a DATEDIFF(millisecond) bucket formula would push the sample one tick
// before the +20 s edge into bucket 1 (it counts millisecond boundaries crossed, not elapsed time).
var db = await ReadTestDb.CreateAsync(fx);
var edge = At(20);
await db.SeedAsync("E",
(At(0).AddTicks(-1), HistorianValue.FromDouble(1000), ReadTestDb.Good), // before start: excluded
(At(0), HistorianValue.FromDouble(1), ReadTestDb.Good), // == start: B0
(edge.AddTicks(-1), HistorianValue.FromDouble(2), ReadTestDb.Good), // last tick of B0
(edge, HistorianValue.FromDouble(4), ReadTestDb.Good), // first tick of B1
(At(40).AddTicks(-1), HistorianValue.FromDouble(8), ReadTestDb.Good), // last tick of B1
(At(40), HistorianValue.FromDouble(2000), ReadTestDb.Good)); // == end: excluded
var s = (await db.Source().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Total, Ct)).Samples;
s.Select(x => x.Value).ShouldBe([3d, 12d]);
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
}
[Fact]
public async Task A_partial_last_interval_is_truncated_at_end()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadProcessedAsync("A", At(0), At(50), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40)]);
s.Select(x => x.Value).ShouldBe([15d, 40d, 50d]);
}
[Fact]
public async Task A_non_positive_interval_is_one_interval_over_the_window()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var src = db.Source();
var avg = (await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single();
avg.Value.ShouldBe(30d); // usable {10, 20, 40, 50}
avg.StatusCode.ShouldBe(UncertainCalc);
avg.SourceTimestampUtc.ShouldBe(At(0));
(await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.FromSeconds(-1), HistoryAggregateType.Count, Ct))
.Samples.Single().Value.ShouldBe(4);
}
[Fact]
public async Task A_reverse_window_is_computed_forward_and_returned_descending()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var s = (await db.Source().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]);
s.Select(x => x.Value).ShouldBe([null, 50d, 40d, 15d]);
}
[Fact]
public async Task Equal_start_and_end_yields_no_intervals()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
(await db.Source().ReadProcessedAsync("A", At(10), At(10), Twenty, HistoryAggregateType.Average, Ct))
.Samples.ShouldBeEmpty();
}
[Fact]
public async Task Outage_markers_are_bad_samples_in_aggregates()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("M",
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
(At(10), HistorianValue.FromDouble(30), ReadTestDb.Good));
var src = db.Source();
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("M", At(0), At(20), Twenty, a, Ct)).Samples.Single();
var avg = await One(HistoryAggregateType.Average);
avg.Value.ShouldBe(20d);
avg.StatusCode.ShouldBe(UncertainCalc);
(await One(HistoryAggregateType.Count)).Value.ShouldBe(2);
(await One(HistoryAggregateType.Start)).Value.ShouldBe(10d);
((double)(await One(HistoryAggregateType.PercentBad)).Value!).ShouldBe(33.333333333333336, 1e-9); // 100 × 1/3
}
[Fact]
public async Task An_interval_of_only_bad_samples_is_BadNoData_except_the_percentages()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("Z",
(At(1), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
(At(2), HistorianValue.FromDouble(5), ReadTestDb.Bad));
var src = db.Source();
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("Z", At(0), At(20), Twenty, a, Ct)).Samples.Single();
(await One(HistoryAggregateType.Average)).StatusCode.ShouldBe(NoDataCalc);
(await One(HistoryAggregateType.Count)).StatusCode.ShouldBe(NoDataCalc);
var pb = await One(HistoryAggregateType.PercentBad);
pb.Value.ShouldBe(100d);
pb.StatusCode.ShouldBe(GoodCalc);
}
[Fact]
public async Task Uncertain_samples_are_used_but_make_the_interval_uncertain()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("U",
(At(1), HistorianValue.FromDouble(4), ReadTestDb.Uncertain),
(At(2), HistorianValue.FromDouble(6), ReadTestDb.Good));
var s = (await db.Source().ReadProcessedAsync("U", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)).Samples.Single();
s.Value.ShouldBe(5d);
s.StatusCode.ShouldBe(UncertainCalc);
}
[Fact]
public async Task String_tags_serve_count_start_end_and_refuse_numeric_aggregates()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("S",
(At(0), HistorianValue.FromString("a"), ReadTestDb.Good),
(At(10), HistorianValue.FromString("b"), ReadTestDb.Good));
var src = db.Source();
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("S", At(0), At(20), Twenty, a, Ct)).Samples.Single();
(await One(HistoryAggregateType.Count)).Value.ShouldBe(2);
(await One(HistoryAggregateType.Start)).Value.ShouldBe("a");
(await One(HistoryAggregateType.End)).Value.ShouldBe("b");
(await One(HistoryAggregateType.PercentGood)).Value.ShouldBe(100d);
foreach (var a in new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Total, HistoryAggregateType.Delta, HistoryAggregateType.Range,
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
})
{
var r = await One(a);
r.StatusCode.ShouldBe(InvalidInputsCalc, a.ToString());
r.Value.ShouldBeNull();
}
}
[Fact]
public async Task DateTime_tags_refuse_numeric_aggregates()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("D", (At(0), HistorianValue.FromDateTime(At(-100)), ReadTestDb.Good));
(await db.Source().ReadProcessedAsync("D", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct))
.Samples.Single().StatusCode.ShouldBe(InvalidInputsCalc);
}
[Fact]
public async Task Boolean_tags_aggregate_as_zero_or_one()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("B",
(At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good),
(At(5), HistorianValue.FromBoolean(false), ReadTestDb.Good),
(At(10), HistorianValue.FromBoolean(true), ReadTestDb.Good),
(At(15), HistorianValue.FromBoolean(true), ReadTestDb.Good));
var src = db.Source();
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("B", At(0), At(20), Twenty, a, Ct)).Samples.Single();
(await One(HistoryAggregateType.Average)).Value.ShouldBe(0.75d);
(await One(HistoryAggregateType.Total)).Value.ShouldBe(3d);
(await One(HistoryAggregateType.Start)).Value.ShouldBe(true);
(await One(HistoryAggregateType.Count)).Value.ShouldBe(4);
}
[Fact]
public async Task Unknown_tag_yields_every_interval_as_BadNoData()
{
var db = await ReadTestDb.CreateAsync(fx);
var s = (await db.Source().ReadProcessedAsync("nope", At(0), At(60), Twenty, HistoryAggregateType.Average, Ct)).Samples;
s.Count.ShouldBe(3);
s.ShouldAllBe(x => x.StatusCode == NoDataCalc && x.Value == null);
}
[Theory]
[InlineData(HistoryAggregateType.Interpolative)]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.StartBound)]
[InlineData(HistoryAggregateType.EndBound)]
[InlineData(HistoryAggregateType.DeltaBounds)]
[InlineData(HistoryAggregateType.DurationGood)]
[InlineData(HistoryAggregateType.DurationBad)]
[InlineData(HistoryAggregateType.WorstQuality)]
[InlineData(HistoryAggregateType.NumberOfTransitions)]
[InlineData(HistoryAggregateType.AnnotationCount)]
[InlineData(HistoryAggregateType.StandardDeviationPopulation)]
[InlineData(HistoryAggregateType.VariancePopulation)]
public async Task Unsupported_aggregates_throw_not_supported_and_are_not_health_failures(HistoryAggregateType agg)
{
var db = await ReadTestDb.CreateAsync(fx);
var src = db.Source();
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
() => src.ReadProcessedAsync("A", At(0), At(20), Twenty, agg, Ct));
ex.Aggregate.ShouldBe(agg);
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()
{
var db = await ReadTestDb.CreateAsync(fx);
var src = db.Source();
var oneTick = TimeSpan.FromTicks(1);
(await src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_000), oneTick, HistoryAggregateType.Count, Ct))
.Samples.Count.ShouldBe(100_000);
await Should.ThrowAsync<ArgumentOutOfRangeException>(
() => src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_001), oneTick, HistoryAggregateType.Count, Ct));
var h = src.GetHealthSnapshot();
h.TotalFailures.ShouldBe(0); // a client-request error, not a backend fault
}
}
@@ -0,0 +1,257 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
/// Shared by the four read test classes. <see cref="T0"/> is deliberately NOT millisecond-aligned.
/// </summary>
internal sealed class ReadTestDb
{
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567);
public const uint Good = 0x00000000u;
public const uint Bad = 0x80000000u;
public const uint BadNoCommunication = 0x80310000u;
public const uint Uncertain = 0x40000000u;
private ReadTestDb(SqlServerHistorianOptions options, SqlHistorianConnection conn, SqlServerSchemaBootstrapper boot)
{
Options = options;
Conn = conn;
Boot = boot;
}
public SqlServerHistorianOptions Options { get; }
public SqlHistorianConnection Conn { get; }
public SqlServerSchemaBootstrapper Boot { get; }
public static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond));
public static async Task<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
{
fx.SkipIfUnavailable();
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
var conn = new SqlHistorianConnection(o);
var boot = new SqlServerSchemaBootstrapper(conn, o);
await boot.EnsureAsync(CancellationToken.None);
return new ReadTestDb(o, conn, boot);
}
/// <summary>A fresh data source (fresh catalog cache) over this schema.</summary>
public SqlServerHistorianDataSource Source() => new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot));
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
int id;
await using (var cmd = Conn.CreateCommand(c,
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
{
cmd.Parameters.AddWithValue("@n", tag);
id = (int)(await cmd.ExecuteScalarAsync())!;
}
foreach (var (t, v, status) in samples)
{
var col = SampleRowMapper.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)");
cmd.Parameters.AddWithValue("@id", id);
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
cmd.Parameters.AddWithValue("@k", col.ValueKind);
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));
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
await cmd.ExecuteNonQueryAsync();
}
return id;
}
/// <summary>The fixture tag A of the brief: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double).</summary>
public Task<int> SeedAAsync(string name = "A") => SeedAsync(name,
(At(0), HistorianValue.FromDouble(10), Good),
(At(10), HistorianValue.FromDouble(20), Good),
(At(20), HistorianValue.FromDouble(30), Bad),
(At(30), HistorianValue.FromDouble(40), Good),
(At(40), HistorianValue.FromDouble(50), Good));
public async Task<long> ScalarAsync(string sql)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
await using var cmd = Conn.CreateCommand(c, sql);
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
}
[Collection(SqlHistorianCollection.Name)]
public sealed class RawReadTests(SqlHistorianFixture fx)
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static DateTime At(double s) => ReadTestDb.At(s);
private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
[Fact]
public async Task Forward_read_is_start_inclusive_end_exclusive_ascending_with_exact_ticks()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var r = await db.Source().ReadRawAsync("A", At(10), At(40), 0, Ct);
Values(r).ShouldBe([20d, 30d, 40d]); // +10 included, +40 (== end) excluded
r.Samples.Select(s => s.SourceTimestampUtc!.Value.Ticks).ShouldBe([At(10).Ticks, At(20).Ticks, At(30).Ticks]);
r.Samples.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, ReadTestDb.Bad, ReadTestDb.Good]);
r.Samples[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
r.Samples[0].ServerTimestampUtc.Ticks.ShouldBe(At(11).Ticks); // stored ServerTimeUtc, not "now"
r.ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task Forward_read_honours_the_limit()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
Values(await db.Source().ReadRawAsync("A", At(0), At(100), 2, Ct)).ShouldBe([10d, 20d]);
}
[Fact]
public async Task Zero_max_values_is_unbounded()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
Values(await db.Source().ReadRawAsync("A", At(-1), At(100), 0, Ct)).ShouldBe([10d, 20d, 30d, 40d, 50d]);
Values(await db.Source().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5);
}
[Fact]
public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
// start=+30, end=+10 ⇒ (+10, +30] descending.
Values(await db.Source().ReadRawAsync("A", At(30), At(10), 0, Ct)).ShouldBe([40d, 30d]);
Values(await db.Source().ReadRawAsync("A", At(100), At(-1), 2, Ct)).ShouldBe([50d, 40d]);
}
[Fact]
public async Task Equal_start_and_end_is_a_point_read()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
Values(await db.Source().ReadRawAsync("A", At(10), At(10), 0, Ct)).ShouldBe([20d]);
(await db.Source().ReadRawAsync("A", At(10).AddTicks(1), At(10).AddTicks(1), 0, Ct)).Samples.ShouldBeEmpty();
}
[Fact]
public async Task A_resumed_page_from_the_last_timestamp_inclusive_continues_without_gaps()
{
// The node manager's paging: read cap N, resume at the last returned timestamp INCLUSIVE, trim it.
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var src = db.Source();
var p1 = await src.ReadRawAsync("A", At(0), At(100), 2, Ct);
var p2 = await src.ReadRawAsync("A", p1.Samples[^1].SourceTimestampUtc!.Value, At(100), 2, Ct);
Values(p1).ShouldBe([10d, 20d]);
Values(p2).ShouldBe([20d, 30d]); // first is the boundary sample the node manager trims
}
[Fact]
public async Task Unknown_tag_is_an_empty_result_and_reads_never_create_tags()
{
var db = await ReadTestDb.CreateAsync(fx);
var src = db.Source();
(await src.ReadRawAsync("nope", At(0), At(10), 0, Ct)).Samples.ShouldBeEmpty();
await src.ReadAtTimeAsync("nope", [At(1)], Ct);
await src.ReadProcessedAsync("nope", At(0), At(10), TimeSpan.FromSeconds(5), HistoryAggregateType.Average, Ct);
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(0);
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task Outage_markers_and_bad_samples_come_back_with_their_bad_status()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAsync("M",
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
(At(7), HistorianValue.FromDouble(99), ReadTestDb.Bad));
var r = await db.Source().ReadRawAsync("M", At(0), At(10), 0, Ct);
r.Samples.Count.ShouldBe(3);
r.Samples[1].Value.ShouldBeNull();
r.Samples[1].StatusCode.ShouldBe(0x80310000u);
r.Samples[2].Value.ShouldBe(99d);
r.Samples[2].StatusCode.ShouldBe(0x80000000u);
}
[Fact]
public async Task Every_kind_round_trips_to_its_CLR_type()
{
var db = await ReadTestDb.CreateAsync(fx);
var dt = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(7);
await db.SeedAsync("K",
(At(0), HistorianValue.FromInt64(-9_000_000_000L), ReadTestDb.Good),
(At(1), HistorianValue.FromBoolean(true), ReadTestDb.Good),
(At(2), HistorianValue.FromString("héllo"), ReadTestDb.Good),
(At(3), HistorianValue.FromDateTime(dt), ReadTestDb.Good),
(At(4), HistorianValue.FromDouble(1.5), ReadTestDb.Uncertain));
var s = (await db.Source().ReadRawAsync("K", At(0), At(5), 0, Ct)).Samples;
s[0].Value.ShouldBe(-9_000_000_000L);
s[1].Value.ShouldBe(true);
s[2].Value.ShouldBe("héllo");
s[3].Value.ShouldBe(dt);
((DateTime)s[3].Value!).Kind.ShouldBe(DateTimeKind.Utc);
s[4].Value.ShouldBe(1.5);
s[4].StatusCode.ShouldBe(0x40000000u);
}
[Fact]
public async Task Successful_reads_are_counted_in_health()
{
var db = await ReadTestDb.CreateAsync(fx);
await db.SeedAAsync();
var src = db.Source();
await src.ReadRawAsync("A", At(0), At(10), 0, Ct);
await src.ReadRawAsync("A", At(0), At(10), 0, Ct);
var h = src.GetHealthSnapshot();
h.TotalQueries.ShouldBe(2);
h.TotalSuccesses.ShouldBe(2);
h.ConsecutiveFailures.ShouldBe(0);
h.LastSuccessTime.ShouldNotBeNull();
h.ProcessConnectionOpen.ShouldBeTrue();
}
[Fact]
public async Task A_missing_schema_surfaces_in_health_LastError_and_is_not_latched()
{
fx.SkipIfUnavailable();
var o = new SqlServerHistorianOptions
{
ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema(), AutoCreateSchema = false,
};
var conn = new SqlHistorianConnection(o);
var boot = new SqlServerSchemaBootstrapper(conn, o);
var src = new SqlServerHistorianDataSource(conn, boot, new SqlServerTagCatalog(conn, boot));
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(1), 0, Ct));
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadEventsAsync(null, At(0), At(1), 0, Ct));
var h = src.GetHealthSnapshot();
h.TotalFailures.ShouldBe(2);
h.ConsecutiveFailures.ShouldBe(2);
h.LastError!.ShouldContain("missing version 1");
h.ProcessConnectionOpen.ShouldBeFalse();
// 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);
(await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty();
src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0);
}
}