using System.Globalization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
///
/// The authored write block (lmxopcua#781 item 2): Update (default) / Insert / Upsert / Procedure, the
/// timestamp stamp, the parser's refusals, every dialect's statement, the routine catalog gate, constraint-failure
/// status mapping, and — over SQLite — the end-to-end Insert / Upsert / stamp paths.
///
public sealed class SqlWriteModeTests
{
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
private const string KvBase =
"\"model\":\"KeyValue\",\"table\":\"plant.sp\",\"keyColumn\":\"tag\",\"keyValue\":\"L1\",\"valueColumn\":\"val\"";
private static string Kv(string write, string extra = "") =>
"{" + KvBase + extra + ",\"writable\":true,\"write\":" + write + "}";
private static SqlTagDefinition Parse(string json)
{
SqlEquipmentTagParser.TryParse(json, "T", out var def).ShouldBeTrue();
return def;
}
// ---- parse ----
[Fact]
public void NoWriteBlock_isTheDefaultUpdate()
{
var def = Parse("{" + KvBase + ",\"writable\":true}");
def.Write.ShouldBeNull();
def.EffectiveWrite.ShouldBe(SqlWriteSpec.Default);
}
[Theory]
[InlineData("Update", SqlWriteMode.Update)]
[InlineData("insert", SqlWriteMode.Insert)]
[InlineData("UPSERT", SqlWriteMode.Upsert)]
public void Mode_parses_caseInsensitively(string mode, SqlWriteMode expected)
=> Parse(Kv($$"""{"mode":"{{mode}}"}""")).Write!.Mode.ShouldBe(expected);
[Fact]
public void ModeAbsent_inABlock_isUpdate_andStampIsRead()
{
var w = Parse(Kv("""{"stampTimestamp":true}""", ",\"timestampColumn\":\"ts\"")).Write!;
w.Mode.ShouldBe(SqlWriteMode.Update);
w.StampTimestamp.ShouldBeTrue();
}
[Fact]
public void Procedure_parsesNames_stripsAMarkerPrefix_andDefaultsTheValueParam()
{
var w = Parse(Kv("""{"mode":"Procedure","procedure":"dbo.SetSp","keyParam":"@TagName","timestampParam":":ts"}""")).Write!;
w.ShouldBe(new SqlWriteSpec(SqlWriteMode.Procedure, false, "dbo.SetSp", "value", "TagName", "ts"));
}
[Fact]
public void Procedure_isTheOneModeAQueryTagMayWrite()
{
const string query = """{"model":"Query","query":"q","column":"c","rowKey":{"column":"line","value":"L1"},"writable":true,"write":""";
Parse(query + """{"mode":"Procedure","procedure":"p","keyParam":"line"}}""").Write!.KeyParameter.ShouldBe("line");
SqlEquipmentTagParser.TryParse(query + """{"mode":"Update"}}""", "T", out _).ShouldBeFalse();
SqlEquipmentTagParser.TryParse(query + """{"mode":"Insert"}}""", "T", out _).ShouldBeFalse();
}
[Theory]
[InlineData("[]", "must be a JSON object")]
[InlineData("""{"mode":"Merge"}""", "'write.mode' must be one of")]
[InlineData("""{"mode":1}""", "'write.mode' must be one of")]
[InlineData("""{"mode":"Update","stampTimestmp":true}""", "unknown key 'stampTimestmp'")]
[InlineData("""{"stampTimestamp":"yes"}""", "must be a JSON boolean")]
[InlineData("""{"stampTimestamp":true}""", "no timestamp column")]
[InlineData("""{"mode":"Update","procedure":"p"}""", "applies only to mode Procedure")]
[InlineData("""{"mode":"Procedure"}""", "needs 'write.procedure'")]
[InlineData("""{"mode":"Procedure","procedure":" "}""", "needs 'write.procedure'")]
[InlineData("""{"mode":"Procedure","procedure":"db.dbo.p"}""", "'schema.procedure' only")]
[InlineData("""{"mode":"Procedure","procedure":"p","valueParam":"v; DROP"}""", "plain parameter name")]
[InlineData("""{"mode":"Procedure","procedure":"p","keyParam":"1abc"}""", "plain parameter name")]
[InlineData("""{"mode":"Procedure","procedure":"p","keyParam":5}""", "must be a string")]
[InlineData("""{"mode":"Procedure","procedure":"p","keyParam":"Value"}""", "same procedure parameter")]
[InlineData("""{"mode":"Procedure","procedure":"p","stampTimestamp":true}""", "does not apply to mode Procedure")]
public void MalformedBlocks_refuseTheTag_andInspectGivesTheParsersReason(string write, string reason)
{
var json = Kv(write);
SqlEquipmentTagParser.TryParse(json, "T", out _).ShouldBeFalse();
SqlEquipmentTagParser.TryParseDetailed(json, "T", out _, out var writeError).ShouldBeFalse();
writeError.ShouldNotBeNull().ShouldContain(reason);
SqlTagDefinitionFactory.DescribeWriteBlock(json).ShouldNotBeNull().ShouldContain(reason);
SqlTagDefinitionFactory.Inspect(json).ShouldHaveSingleItem().ShouldContain(reason);
}
[Fact]
public void UpdateAndUpsert_needARowKey_butInsertAppendsToANewestRowTag()
{
static string Newest(string mode) =>
$$$"""{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"ts"},"writable":true,"write":{"mode":"{{{mode}}}","stampTimestamp":true}}""";
SqlTagDefinitionFactory.DescribeWriteBlock(Newest("Update")).ShouldNotBeNull().ShouldContain("needs a row key");
SqlTagDefinitionFactory.DescribeWriteBlock(Newest("Upsert")).ShouldNotBeNull().ShouldContain("needs a row key");
SqlTagDefinitionFactory.DescribeWriteBlock(Newest("Insert")).ShouldBeNull(); // positive control
}
[Fact]
public void KeyParam_onAModelWithNoKey_isRefused()
=> SqlTagDefinitionFactory.DescribeWriteBlock(
"""{"model":"Query","query":"q","column":"c","write":{"mode":"Procedure","procedure":"p","keyParam":"k"}}""")
.ShouldNotBeNull().ShouldContain("no key to bind");
[Fact]
public void DescribeWriteBlock_isQuiet_forAValidBlock_noBlock_orABlobBrokenElsewhere()
{
SqlTagDefinitionFactory.DescribeWriteBlock(Kv("""{"mode":"Upsert"}""")).ShouldBeNull();
SqlTagDefinitionFactory.DescribeWriteBlock("{" + KvBase + "}").ShouldBeNull();
// The tag's own fields are what fail (no table) — Inspect reports that, not the write block.
SqlTagDefinitionFactory.DescribeWriteBlock("""{"model":"KeyValue","write":{"mode":"Nope"}}""").ShouldBeNull();
}
// ---- shared support table ----
[Theory]
[InlineData(SqlProvider.Odbc, SqlWriteMode.Update, true)]
[InlineData(SqlProvider.Odbc, SqlWriteMode.Insert, true)]
[InlineData(SqlProvider.Odbc, SqlWriteMode.Upsert, false)]
[InlineData(SqlProvider.Odbc, SqlWriteMode.Procedure, false)]
[InlineData(SqlProvider.SqlServer, SqlWriteMode.Upsert, true)]
[InlineData(SqlProvider.Oracle, SqlWriteMode.Procedure, true)]
public void SupportTable(SqlProvider provider, SqlWriteMode mode, bool supported)
=> SqlWriteSupport.IsSupported(provider, mode).ShouldBe(supported);
///
/// The shared table (deploy's verdict) and each real dialect's statements (runtime's) must agree: a mode the
/// table says is served has a statement, and one it refuses has none — so deploy can never accept a mode the
/// driver then refuses, or refuse one it would serve.
///
[Fact]
public void EveryRealDialect_buildsAStatement_exactlyForTheModesTheSharedTableServes()
{
foreach (var provider in Enum.GetValues())
{
var dialect = SqlDialects.TryCreate(provider).ShouldNotBeNull();
dialect.SupportsWriteMode(SqlWriteMode.Upsert).ShouldBe(SqlWriteSupport.IsSupported(provider, SqlWriteMode.Upsert));
(dialect.UpsertSql("t", "k", "m", [("v", "n")]) is not null)
.ShouldBe(SqlWriteSupport.IsSupported(provider, SqlWriteMode.Upsert), provider.ToString());
(dialect.ProcedureCallSql("p", [("value", "m")]) is not null && dialect.ListRoutinesSql is not null)
.ShouldBe(SqlWriteSupport.IsSupported(provider, SqlWriteMode.Procedure), provider.ToString());
}
}
// ---- planner: per mode, per dialect ----
private static SqlTagDefinition KvTag(SqlWriteSpec? write, string? ts = null) =>
new("A", SqlTagModel.KeyValue, "plant.sp", KeyColumn: "tag", KeyValue: "L1", ValueColumn: "val",
TimestampColumn: ts, Writable: true, Write: write);
private static SqlWritePlan Plan(SqlTagDefinition tag, ISqlDialect dialect, object? value = null)
{
SqlWritePlanner.TryPlan(tag, value ?? 5.5, dialect, Now, out var plan).ShouldBe(SqlStatusCodes.Good);
return plan!;
}
[Fact]
public void Update_withStamp_setsValueThenStamp_thenTheKey_inMarkerOrder()
{
var plan = Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Update, StampTimestamp: true), ts: "ts"), new OdbcDialect());
plan.SqlText.ShouldBe("UPDATE \"plant\".\"sp\" SET \"val\" = ?, \"ts\" = ? WHERE \"tag\" = ?");
plan.Parameters.ShouldBe([5.5, Now, "L1"]);
plan.Mode.ShouldBe(SqlWriteMode.Update);
}
[Fact]
public void Update_withoutStamp_isByteIdenticalToTheOriginalStatement()
=> Plan(KvTag(null, ts: "ts"), new SqlServerDialect()).SqlText
.ShouldBe("UPDATE [plant].[sp] SET [val] = @v WHERE [tag] = @k");
[Fact]
public void Insert_keyValue_insertsKeyValueAndStamp_inColumnOrder()
{
var plan = Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Insert, StampTimestamp: true), ts: "ts"), new OdbcDialect());
plan.SqlText.ShouldBe("INSERT INTO \"plant\".\"sp\" (\"tag\", \"val\", \"ts\") VALUES (?, ?, ?)");
plan.Parameters.ShouldBe(["L1", 5.5, Now]);
}
[Fact]
public void Insert_onANewestRowTag_appendsTheValueAndItsOrderingColumn_withNoKey()
{
var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "hist", ColumnName: "temp",
RowSelectorTopByTimestamp: "sample_ts", Writable: true, Write: new SqlWriteSpec(SqlWriteMode.Insert, true));
var plan = Plan(tag, new SqlServerDialect());
plan.SqlText.ShouldBe("INSERT INTO [hist] ([temp], [sample_ts]) VALUES (@v, @t)");
plan.ParameterBindNames.ShouldBe(["@v", "@t"]);
}
[Fact]
public void Upsert_sqlServer_isAHoldlockMerge()
{
var plan = Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Upsert, true), ts: "ts"), new SqlServerDialect());
plan.SqlText.ShouldBe(
"MERGE INTO [plant].[sp] WITH (HOLDLOCK) AS tgt USING (SELECT 1 AS one) AS src ON tgt.[tag] = @k " +
"WHEN MATCHED THEN UPDATE SET [val] = @v, [ts] = @t " +
"WHEN NOT MATCHED THEN INSERT ([tag], [val], [ts]) VALUES (@k, @v, @t);");
plan.ParameterBindNames.ShouldBe(["@k", "@v", "@t"]);
plan.Parameters.ShouldBe(["L1", 5.5, Now]);
plan.MaxRowsAffected.ShouldBe(1);
}
[Fact]
public void Upsert_postgres_isInsertOnConflict()
=> Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Upsert)), new PostgresDialect()).SqlText.ShouldBe(
"INSERT INTO \"plant\".\"sp\" (\"tag\", \"val\") VALUES (@k, @v) ON CONFLICT (\"tag\") DO UPDATE SET \"val\" = EXCLUDED.\"val\"");
[Fact]
public void Upsert_mySql_isOnDuplicateKeyUpdate_andAllowsTwoRows()
{
var plan = Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Upsert)), new MySqlDialect());
plan.SqlText.ShouldBe("INSERT INTO `plant`.`sp` (`tag`, `val`) VALUES (@k, @v) ON DUPLICATE KEY UPDATE `val` = @v");
plan.MaxRowsAffected.ShouldBe(2);
}
[Fact]
public void Upsert_oracle_isAMergeFromDual_withNamespacedBinds()
{
var plan = Plan(KvTag(new SqlWriteSpec(SqlWriteMode.Upsert)), new OracleDialect());
plan.SqlText.ShouldBe(
"MERGE INTO \"plant\".\"sp\" tgt USING (SELECT 1 AS one FROM dual) src ON (tgt.\"tag\" = :p_k) " +
"WHEN MATCHED THEN UPDATE SET tgt.\"val\" = :p_v WHEN NOT MATCHED THEN INSERT (\"tag\", \"val\") VALUES (:p_k, :p_v)");
plan.ParameterBindNames.ShouldBe(["p_k", "p_v"]);
}
[Fact]
public void Upsert_andProcedure_onOdbc_areNotSupported()
{
SqlWritePlanner.TryPlan(KvTag(new SqlWriteSpec(SqlWriteMode.Upsert)), 1.0, new OdbcDialect(), Now, out _)
.ShouldBe(SqlStatusCodes.BadNotSupported);
SqlWritePlanner.TryPlan(KvTag(new SqlWriteSpec(SqlWriteMode.Procedure, Procedure: "p", ValueParameter: "value")),
1.0, new OdbcDialect(), Now, out _).ShouldBe(SqlStatusCodes.BadNotSupported);
}
private static SqlTagDefinition ProcTag(string? key = "TagName", string? ts = "Ts") =>
KvTag(new SqlWriteSpec(SqlWriteMode.Procedure, false, "dbo.SetSp", "Value", key, ts));
[Theory]
[InlineData(SqlProvider.SqlServer, "EXEC [dbo].[SetSp] @Value = @v, @TagName = @k, @Ts = @t")]
[InlineData(SqlProvider.Postgres, "CALL \"dbo\".\"SetSp\"(Value => @v, TagName => @k, Ts => @t)")]
[InlineData(SqlProvider.MySql, "CALL `dbo`.`SetSp`(@v, @k, @t)")]
[InlineData(SqlProvider.Oracle, "BEGIN \"dbo\".\"SetSp\"(Value => :p_v, TagName => :p_k, Ts => :p_t); END;")]
public void Procedure_callsByName_orPositionallyOnMySql_valueKeyStampInThatOrder(SqlProvider provider, string sql)
{
var plan = Plan(ProcTag(), SqlDialects.TryCreate(provider)!);
plan.SqlText.ShouldBe(sql);
plan.Parameters.ShouldBe([5.5, "L1", Now]);
plan.Mode.ShouldBe(SqlWriteMode.Procedure);
}
[Fact]
public void Procedure_bindsOnlyWhatTheBlockNames()
{
var plan = Plan(ProcTag(key: null, ts: null), new SqlServerDialect());
plan.SqlText.ShouldBe("EXEC [dbo].[SetSp] @Value = @v");
plan.Parameters.ShouldBe([5.5]);
}
[Fact]
public void AHostileValue_isBound_inEveryMode()
{
const string hostile = "x'); DROP TABLE sp; --";
foreach (var write in new[]
{
new SqlWriteSpec(SqlWriteMode.Insert), new SqlWriteSpec(SqlWriteMode.Upsert),
new SqlWriteSpec(SqlWriteMode.Procedure, false, "p", "value"),
})
{
var plan = Plan(KvTag(write) with { DeclaredType = DriverDataType.String }, new SqlServerDialect(), hostile);
plan.SqlText.ShouldNotContain("DROP");
plan.Parameters.ShouldContain(hostile);
}
}
// ---- catalog gate: the procedure is an identifier like any other ----
private static SqlCatalog Catalog(params string[] procedures) => new(
"dbo", ["dbo"],
new Dictionary>(StringComparer.Ordinal) { ["dbo"] = ["sp"] },
new Dictionary>(StringComparer.Ordinal) { ["dbo.sp"] = ["tag", "val"] },
new Dictionary>(StringComparer.Ordinal) { ["dbo"] = procedures });
private static SqlTagDefinition GateTag(string procedure) =>
new("A", SqlTagModel.KeyValue, "sp", KeyColumn: "tag", KeyValue: "L1", ValueColumn: "val", Writable: true,
Write: new SqlWriteSpec(SqlWriteMode.Procedure, false, procedure, "value"));
[Fact]
public void Gate_resolvesTheProcedure_toTheCatalogSpelling()
{
var result = SqlCatalogGate.Apply([GateTag("setsp")], Catalog("SetSp"), new SqlServerDialect());
result.Rejected.ShouldBeEmpty();
result.Accepted.ShouldHaveSingleItem().Write!.Procedure.ShouldBe("dbo.SetSp");
}
[Fact]
public void Gate_rejectsAnUnknownProcedure_byField()
{
var result = SqlCatalogGate.Apply([GateTag("Nope")], Catalog("SetSp"), new SqlServerDialect());
result.Accepted.ShouldBeEmpty();
var rejection = result.Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(SqlCatalogGate.ProcedureField);
rejection.Reason.ShouldContain("'Nope' does not exist");
}
[Fact]
public void Gate_checksAQueryTagsProcedure_too()
{
var query = new SqlTagDefinition("Q", SqlTagModel.Query, string.Empty, ColumnName: "c", QueryName: "q",
Writable: true, Write: new SqlWriteSpec(SqlWriteMode.Procedure, false, "Missing", "value"));
SqlCatalogGate.Apply([query], Catalog("SetSp"), new SqlServerDialect()).Rejected.ShouldHaveSingleItem();
SqlCatalogGate.Apply([query with { Write = query.Write! with { Procedure = "SetSp" } }], Catalog("SetSp"),
new SqlServerDialect()).Accepted.ShouldHaveSingleItem().Write!.Procedure.ShouldBe("dbo.SetSp");
}
[Fact]
public void Gate_refusesAProcedure_onADialectWithNoRoutineCatalog()
=> SqlCatalogGate.Apply([GateTag("SetSp")], Catalog("SetSp"), new OdbcDialect())
.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("no stored-procedure catalog");
// ---- failure classification → status ----
[Theory]
[InlineData("23505", SqlWriteFailure.UniqueViolation)]
[InlineData("23502", SqlWriteFailure.IntegrityViolation)]
[InlineData("23000", SqlWriteFailure.IntegrityViolation)]
[InlineData("22003", SqlWriteFailure.DataException)]
[InlineData("42501", SqlWriteFailure.Other)] // insufficient privilege stays a communication error
[InlineData(null, SqlWriteFailure.Other)]
public void SqlState_classification(string? sqlState, SqlWriteFailure expected)
=> SqlWriteFailures.FromSqlState(sqlState).ShouldBe(expected);
[Theory]
[InlineData(SqlWriteFailure.UniqueViolation, 0x809F0000u)] // BadEntryExists
[InlineData(SqlWriteFailure.IntegrityViolation, 0x803C0000u)] // BadOutOfRange
[InlineData(SqlWriteFailure.DataException, 0x803C0000u)]
[InlineData(SqlWriteFailure.Other, 0x80050000u)] // BadCommunicationError
public void Failure_mapsToTheDocumentedStatus(SqlWriteFailure failure, uint status)
=> SqlDriver.MapWriteFailure(failure).ShouldBe(status);
[Fact]
public void AnUnrelatedException_isNeverAConstraint_onAnyDialect()
{
foreach (var provider in Enum.GetValues())
SqlDialects.TryCreate(provider)!.ClassifyWriteFailure(new InvalidOperationException()).ShouldBe(SqlWriteFailure.Other);
}
// ---- end to end over SQLite ----
private const string Setpoints = "Setpoints";
private static SqlitePollFixture SetpointFixture()
{
var fixture = new SqlitePollFixture();
fixture.Execute($"CREATE TABLE {Setpoints} (tag TEXT NOT NULL PRIMARY KEY, sp REAL NOT NULL, ts TEXT NULL)");
fixture.Execute($"INSERT INTO {Setpoints} VALUES ('Present', 1.0, NULL)");
return fixture;
}
private static string SpTag(string key, string write) => $$$"""
{"model":"KeyValue","table":"{{{Setpoints}}}","keyColumn":"tag","keyValue":"{{{key}}}","valueColumn":"sp",
"timestampColumn":"ts","type":"Float64","writable":true,"write":{{{write}}}}
""";
[Fact]
public async Task Insert_addsANewRow_andASecondInsertOfTheKey_isBadEntryExists()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture, Entry("New", SpTag("New", """{"mode":"Insert"}""")));
(await WriteAsync(driver, "New", 7.5)).ShouldBe(SqlStatusCodes.Good);
Scalar(fixture, "SELECT sp FROM Setpoints WHERE tag = 'New'").ShouldBe(7.5);
(await WriteAsync(driver, "New", 8.5)).ShouldBe(SqlStatusCodes.BadEntryExists);
Scalar(fixture, "SELECT sp FROM Setpoints WHERE tag = 'New'").ShouldBe(7.5);
}
[Fact]
public async Task Insert_violatingNotNull_isBadOutOfRange_andCommitsNothing()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture, Entry("New", SpTag("New", """{"mode":"Insert"}""")));
(await WriteAsync(driver, "New", null)).ShouldBe(SqlStatusCodes.BadOutOfRange);
Scalar(fixture, "SELECT COUNT(*) FROM Setpoints").ShouldBe(1.0);
}
[Fact]
public async Task Upsert_insertsAnAbsentKey_andUpdatesAPresentOne()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture,
Entry("Absent", SpTag("Absent", """{"mode":"Upsert"}""")),
Entry("Present", SpTag("Present", """{"mode":"Upsert"}""")));
(await WriteAsync(driver, "Absent", 3.0)).ShouldBe(SqlStatusCodes.Good);
(await WriteAsync(driver, "Present", 4.0)).ShouldBe(SqlStatusCodes.Good);
Scalar(fixture, "SELECT sp FROM Setpoints WHERE tag = 'Absent'").ShouldBe(3.0);
Scalar(fixture, "SELECT sp FROM Setpoints WHERE tag = 'Present'").ShouldBe(4.0);
Scalar(fixture, "SELECT COUNT(*) FROM Setpoints").ShouldBe(2.0);
}
/// The same absent key under the default Update is BadNoData and inserts nothing — the back-compat pin.
[Fact]
public async Task Update_toAnAbsentKey_isStillBadNoData()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture, Entry("Absent", SpTag("Absent", """{"mode":"Update"}""")));
(await WriteAsync(driver, "Absent", 3.0)).ShouldBe(SqlStatusCodes.BadNoData);
Scalar(fixture, "SELECT COUNT(*) FROM Setpoints").ShouldBe(1.0);
}
[Fact]
public async Task StampTimestamp_setsTheTimestampColumn_toTheWriteTime_andWithoutItTheColumnIsUntouched()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture,
Entry("Stamped", SpTag("Present", """{"mode":"Update","stampTimestamp":true}""")));
var before = DateTime.UtcNow.AddSeconds(-1);
(await WriteAsync(driver, "Stamped", 9.0)).ShouldBe(SqlStatusCodes.Good);
using var command = fixture.Connection.CreateCommand();
command.CommandText = "SELECT ts FROM Setpoints WHERE tag = 'Present'";
var stamped = DateTime.Parse((string)command.ExecuteScalar()!, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
stamped.ShouldBeGreaterThan(before);
stamped.ShouldBeLessThan(DateTime.UtcNow.AddSeconds(1));
}
[Fact]
public async Task Procedure_onADialectWithNoRoutineCatalog_isRejectedByTheGate_notExecuted()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture,
Entry("Proc", SpTag("Present", """{"mode":"Procedure","procedure":"SetSp"}""")));
(await WriteAsync(driver, "Proc", 2.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
Scalar(fixture, "SELECT sp FROM Setpoints WHERE tag = 'Present'").ShouldBe(1.0);
}
[Fact]
public async Task Discover_advertisesOperate_forAnInsertOnANewestRowTag()
{
using var fixture = SetpointFixture();
await using var driver = await StartAsync(fixture, Entry("Append", $$$"""
{"model":"WideRow","table":"{{{SqlitePollFixture.WideRowTable}}}","columnName":"oven_temp",
"rowSelector":{"topByTimestamp":"sample_ts"},"writable":true,"write":{"mode":"Insert","stampTimestamp":true}}
"""));
var builder = new Capture();
await driver.DiscoverAsync(builder, CancellationToken.None);
builder.Classes["Append"].ShouldBe(SecurityClassification.Operate);
}
// ---- helpers ----
private static RawTagEntry Entry(string rawPath, string json) => new(rawPath, json, WriteIdempotent: false);
private static async Task WriteAsync(SqlDriver driver, string reference, object? value)
=> (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode;
private static double Scalar(SqlitePollFixture fixture, string sql)
{
using var command = fixture.Connection.CreateCommand();
command.CommandText = sql;
return Convert.ToDouble(command.ExecuteScalar(), CultureInfo.InvariantCulture);
}
private static async Task StartAsync(SqlitePollFixture fixture, params RawTagEntry[] tags)
{
var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = tags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
AllowWrites = true,
},
"sql-write-modes", new SqliteDialect(), fixture.ConnectionString, fixture.Factory);
await driver.InitializeAsync("{}", CancellationToken.None);
return driver;
}
private sealed class Capture : IAddressSpaceBuilder
{
public Dictionary Classes { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Classes[attributeInfo.FullName] = attributeInfo.SecurityClass;
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}