feat(historian): SQL Server HistoryUpdate data updater (T11)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>Status codes owned by the updater (named consts so <c>StatusCodeParityTests</c> sees them).</summary>
|
||||
internal static class SqlHistorianUpdateStatusCodes
|
||||
{
|
||||
/// <summary>A value that cannot be stored (Double NaN / ±Infinity).</summary>
|
||||
public const uint BadInvalidArgument = 0x80AB0000u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA HistoryUpdate against the SQL Server sample table (spec §4 "Updates"). One transaction per call;
|
||||
/// exactly one status per input item, in input order.
|
||||
/// <para><b>Duplicate timestamps within one request</b> (compared at full 100 ns tick precision):
|
||||
/// Insert — the first occurrence is applied, later ones are <c>BadEntryExists</c>; Replace/Update — last wins,
|
||||
/// and every earlier occurrence of that timestamp reports the same code as the winning one.
|
||||
/// DeleteAtTime — the first occurrence reports the real outcome, later ones <c>BadNoEntryExists</c>.</para>
|
||||
/// <para><b>Invalid items.</b> A null or <see cref="DateTime.MinValue"/> timestamp is
|
||||
/// <c>BadInvalidTimestamp</c>. A Double NaN/±Infinity cannot live in the SQL <c>float</c> column and is
|
||||
/// rejected with <c>BadInvalidArgument</c> (not silently stored as Empty). Other items still proceed.</para>
|
||||
/// <para><b>Unknown tag.</b> Insert/Update create it via <see cref="SqlServerTagCatalog"/>; Replace and the
|
||||
/// deletes report <c>BadNoEntryExists</c> (DeleteRaw: <c>BadNoData</c>) without creating anything.</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
{
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerTagCatalog _tags;
|
||||
|
||||
/// <summary>Creates the updater.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="tags">Tag catalog (runs the schema bootstrap itself).</param>
|
||||
public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerTagCatalog tags)
|
||||
{
|
||||
_conn = conn;
|
||||
_tags = tags;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
|
||||
IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
var results = new uint[values.Count];
|
||||
var winner = new Dictionary<long, int>(); // tick -> index of the item actually sent
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var v = values[i];
|
||||
if (v.TimestampUtc is not { } ts || ts == DateTime.MinValue)
|
||||
{
|
||||
results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp;
|
||||
continue;
|
||||
}
|
||||
if (v.Value.Kind == HistorianValueKind.Double && !double.IsFinite(v.Value.Number))
|
||||
{
|
||||
results[i] = SqlHistorianUpdateStatusCodes.BadInvalidArgument;
|
||||
continue;
|
||||
}
|
||||
if (!winner.TryGetValue(ts.Ticks, out _))
|
||||
winner[ts.Ticks] = i;
|
||||
else if (mode == HistoryUpdateMode.Insert)
|
||||
results[i] = SqlHistorianStatusCodes.BadEntryExists; // first occurrence stays the winner
|
||||
else
|
||||
winner[ts.Ticks] = i; // last wins
|
||||
}
|
||||
|
||||
var send = winner.Values.OrderBy(i => i).ToList();
|
||||
if (send.Count == 0) return results;
|
||||
|
||||
var tagId = mode == HistoryUpdateMode.Replace
|
||||
? await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false)
|
||||
: await _tags.GetOrCreateTagIdAsync(tag, DataTypeFor(values[send[0]].Value.Kind), ct).ConfigureAwait(false);
|
||||
|
||||
var outcome = tagId is null
|
||||
? new Dictionary<long, uint>()
|
||||
: await MergeAsync(tagId.Value, mode, send.Select(i => new SqlSampleRow(
|
||||
values[i].TimestampUtc!.Value, values[i].Value, values[i].StatusCode)).ToList(), ct).ConfigureAwait(false);
|
||||
|
||||
// No OUTPUT row means the MERGE's absent branch: Insert => matched (exists); Replace => not matched.
|
||||
var absent = mode == HistoryUpdateMode.Replace
|
||||
? SqlHistorianStatusCodes.BadNoEntryExists
|
||||
: SqlHistorianStatusCodes.BadEntryExists;
|
||||
foreach (var i in send)
|
||||
results[i] = outcome.TryGetValue(values[i].TimestampUtc!.Value.Ticks, out var code) ? code : absent;
|
||||
|
||||
// Superseded earlier duplicates (Replace/Update) share the winner's code.
|
||||
if (mode != HistoryUpdateMode.Insert)
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
if (values[i].TimestampUtc is { } ts && results[i] == 0
|
||||
&& winner.TryGetValue(ts.Ticks, out var w) && w != i)
|
||||
results[i] = results[w];
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false);
|
||||
if (tagId is null) return SqlHistorianStatusCodes.BadNoData;
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
int rows;
|
||||
await using (var cmd = _conn.CreateCommand(conn,
|
||||
$"DELETE FROM {_conn.Q("Sample")} WHERE TagId = @tagId AND SourceTimeUtc >= @s AND SourceTimeUtc < @e", tx))
|
||||
{
|
||||
cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@s", startUtc);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@e", endUtc);
|
||||
rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
return rows > 0 ? SqlHistorianStatusCodes.Good : SqlHistorianStatusCodes.BadNoData;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(timesUtc);
|
||||
var results = new uint[timesUtc.Count];
|
||||
var first = new Dictionary<long, int>();
|
||||
for (var i = 0; i < timesUtc.Count; i++)
|
||||
{
|
||||
results[i] = SqlHistorianStatusCodes.BadNoEntryExists;
|
||||
if (timesUtc[i] == DateTime.MinValue) results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp;
|
||||
else first.TryAdd(timesUtc[i].Ticks, i);
|
||||
}
|
||||
if (first.Count == 0) return results;
|
||||
var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false);
|
||||
if (tagId is null) return results;
|
||||
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await using (var cmd = _conn.CreateCommand(conn,
|
||||
$"DELETE s OUTPUT deleted.SourceTimeUtc FROM {_conn.Q("Sample")} AS s JOIN @times AS t ON s.SourceTimeUtc = t.T WHERE s.TagId = @tagId", tx))
|
||||
{
|
||||
cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value });
|
||||
_conn.AddStructured(cmd, "@times", "TimeRow",
|
||||
SampleRowMapper.ToTimeRecords(first.Keys.Select(t => new DateTime(t, DateTimeKind.Utc))));
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await rd.ReadAsync(ct).ConfigureAwait(false))
|
||||
if (first.TryGetValue(SqlHistorianConnection.GetUtc(rd, 0).Ticks, out var idx))
|
||||
results[idx] = SqlHistorianStatusCodes.Good;
|
||||
}
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, uint>> MergeAsync(int tagId, HistoryUpdateMode mode,
|
||||
List<SqlSampleRow> rows, CancellationToken ct)
|
||||
{
|
||||
var sql = $"MERGE {_conn.Q("Sample")} WITH (HOLDLOCK) AS t USING @rows AS r ON t.TagId = @tagId AND t.SourceTimeUtc = r.SourceTimeUtc ";
|
||||
if (mode != HistoryUpdateMode.Insert)
|
||||
sql += "WHEN MATCHED THEN UPDATE SET ValueKind = r.ValueKind, NumValue = r.NumValue, IntValue = r.IntValue, " +
|
||||
"StrValue = r.StrValue, StatusCode = r.StatusCode, ServerTimeUtc = SYSUTCDATETIME() ";
|
||||
if (mode != HistoryUpdateMode.Replace)
|
||||
sql += "WHEN NOT MATCHED THEN INSERT (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
||||
"VALUES (@tagId, r.SourceTimeUtc, r.ValueKind, r.NumValue, r.IntValue, r.StrValue, r.StatusCode, SYSUTCDATETIME()) ";
|
||||
sql += "OUTPUT $action, r.SourceTimeUtc;";
|
||||
|
||||
var outcome = new Dictionary<long, uint>();
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await using (var cmd = _conn.CreateCommand(conn, sql, tx))
|
||||
{
|
||||
cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId });
|
||||
_conn.AddStructured(cmd, "@rows", "SampleRow", SampleRowMapper.ToSampleRecords(rows));
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await rd.ReadAsync(ct).ConfigureAwait(false))
|
||||
outcome[SqlHistorianConnection.GetUtc(rd, 1).Ticks] = rd.GetString(0) == "INSERT"
|
||||
? SqlHistorianStatusCodes.GoodEntryInserted
|
||||
: SqlHistorianStatusCodes.GoodEntryReplaced;
|
||||
}
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// T9 owns the canonical HistorianValueKind -> Tag.DataType mapping; a later fix wave will dedupe the two.
|
||||
private static byte DataTypeFor(HistorianValueKind kind) => (byte)(kind switch
|
||||
{
|
||||
HistorianValueKind.Int64 => DriverDataType.Int64,
|
||||
HistorianValueKind.Boolean => DriverDataType.Boolean,
|
||||
HistorianValueKind.String => DriverDataType.String,
|
||||
HistorianValueKind.DateTime => DriverDataType.DateTime,
|
||||
_ => DriverDataType.Float64,
|
||||
});
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
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<List<(DateTime Time, double? Num, int Status)>> 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<long> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user