using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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; // 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); private static DateTime T(int n) => T0.AddTicks(1234567L * n + 3); private (SqlServerHistorianDataUpdater U, string Schema) Make() { fx.SkipIfUnavailable(); var schema = SqlHistorianFixture.UniqueSchema(); var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = schema }; var conn = new SqlHistorianConnection(o); var boot = new SqlServerSchemaBootstrapper(conn, o); return (new SqlServerHistorianDataUpdater(conn, new SqlServerTagCatalog(conn, boot)), schema); } private static HistorizationValue V(DateTime? t, double d, uint status = Good) => new(t, HistorianValue.FromDouble(d), status); private async Task> RowsAsync(string schema, string tag) { await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); await using var cmd = c.CreateCommand(); cmd.CommandText = $"SELECT s.SourceTimeUtc, s.NumValue, s.StatusCode FROM [{schema}].Sample s JOIN [{schema}].Tag g ON g.TagId=s.TagId WHERE g.Name=@n ORDER BY s.SourceTimeUtc"; cmd.Parameters.AddWithValue("@n", tag); var list = new List<(DateTime, double?, int)>(); await using var rd = await cmd.ExecuteReaderAsync(); while (await rd.ReadAsync()) list.Add((DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc), rd.IsDBNull(1) ? null : rd.GetDouble(1), rd.GetInt32(2))); return list; } private async Task TagCountAsync(string schema) { await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); await using var cmd = c.CreateCommand(); cmd.CommandText = $"SELECT COUNT(*) FROM [{schema}].Tag"; return Convert.ToInt64(await cmd.ExecuteScalarAsync()); } private static readonly CancellationToken Ct = CancellationToken.None; [Fact] public async Task Insert_new_is_GoodEntryInserted_and_existing_is_BadEntryExists_in_order() { 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]); // 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]); 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 } [Fact] public async Task Replace_existing_is_GoodEntryReplaced_with_new_value_and_status_missing_is_BadNoEntryExists() { var (u, s) = Make(); 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]); var rows = await RowsAsync(s, "t"); rows.Count.ShouldBe(2); // T(5) was not created rows[0].Num.ShouldBe(11d); rows[0].Status.ShouldBe((int)Good); rows[1].Num.ShouldBe(22d); rows[1].Status.ShouldBe(unchecked((int)UncertainStatus)); // replaced value visible with its new status } [Fact] public async Task Update_upserts_inserted_or_replaced_per_item() { 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]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([10d, 2d]); } [Fact] public async Task Unknown_tag_Insert_and_Update_create_it_but_Replace_and_deletes_do_not() { 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]); (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 TagCountAsync(s)).ShouldBe(2); } [Fact] public async Task Null_and_MinValue_timestamps_are_BadInvalidTimestamp_and_others_proceed() { 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]); (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 TagCountAsync(s2)).ShouldBe(1); // only "seed"; "x" was never created } [Fact] public async Task NaN_and_Infinity_are_rejected_with_BadInvalidArgument_and_others_proceed() { 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]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([2d]); } [Fact] public async Task Duplicate_timestamp_in_one_Insert_request_second_is_BadEntryExists_first_wins() { 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]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([1d, 2d]); } [Fact] public async Task Duplicate_timestamp_in_one_Replace_or_Update_request_last_wins_and_all_share_the_code() { 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]); (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]); (await RowsAsync(s, "t")).Last().Num.ShouldBe(8d); } [Fact] public async Task Replace_and_Update_can_change_the_value_kind_and_clear_stale_columns() { 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]); var rows = await RowsAsync(s, "t"); rows.Single().Num.ShouldBeNull(); // NumValue cleared by the String replace } [Fact] public async Task DeleteRaw_is_half_open_Good_when_rows_removed_BadNoData_when_none() { var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); // [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); // 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 RowsAsync(s, "t")).Count.ShouldBe(1); } [Fact] public async Task DeleteAtTime_reports_per_time_in_order_with_exact_tick_matching() { 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]); (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(2)]); } [Fact] public async Task DeleteAtTime_duplicate_time_first_reports_Good_later_BadNoEntryExists() { 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 RowsAsync(s, "t")).ShouldBeEmpty(); } }