f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
Item 2: a tag's optional `write` block picks the statement the driver builds from its own catalog-validated identifiers (no authored SQL; values always bound): - Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData) - Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag) - Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL - Procedure: named stored procedure resolved against the routine catalog by the catalog gate (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional) - stampTimestamp sets the tag's timestamp column to the write time (UTC) Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else, permission denied included, stays BadCommunicationError. WriteIdempotent stays false. The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI editor surfaces the same message. Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login limited to their write targets. Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end), DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server, PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
533 lines
25 KiB
C#
533 lines
25 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The authored <c>write</c> 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.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryRealDialect_buildsAStatement_exactlyForTheModesTheSharedTableServes()
|
|
{
|
|
foreach (var provider in Enum.GetValues<SqlProvider>())
|
|
{
|
|
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<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["sp"] },
|
|
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo.sp"] = ["tag", "val"] },
|
|
new Dictionary<string, IReadOnlyList<string>>(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<SqlProvider>())
|
|
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);
|
|
}
|
|
|
|
/// <summary>The same absent key under the default Update is BadNoData and inserts nothing — the back-compat pin.</summary>
|
|
[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<uint> 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<SqlDriver> 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<string, SecurityClassification> 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) { }
|
|
}
|
|
}
|
|
}
|
|
}
|