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) { } } } } }