Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlWriteBlockTests.cs
T
Joseph Doherty f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
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
2026-11-09 22:59:09 -05:00

68 lines
3.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// lmxopcua#781 item 2: a Sql tag's <c>write</c> block is checked at deploy by the driver's own parser
/// (<c>SqlWriteBlockInvalid</c>), and its mode against the instance's provider (<c>SqlWriteModeUnsupported</c>).
/// Both always block.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlWriteBlockTests
{
private static string Kv(string write) =>
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"a","valueColumn":"v","writable":true,"write":""" + write + "}";
private static DraftSnapshot Draft(string driverConfig, string tagConfig, string driverType = "Sql") => new()
{
GenerationId = 1, ClusterId = "c",
DriverInstances = [new DriverInstance
{ DriverInstanceId = "di", ClusterId = "c", Name = "sql", DriverType = driverType, DriverConfig = driverConfig }],
Devices = [new Device { DeviceId = "dev", DriverInstanceId = "di", Name = "d", DeviceConfig = "{}" }],
Tags = [new Tag { TagId = "t1", DeviceId = "dev", Name = "t", DataType = "Float64",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = tagConfig }],
};
[Theory]
[InlineData("""{"mode":"Merge"}""", "'write.mode'")]
[InlineData("""{"mode":"Update","oops":1}""", "unknown key 'oops'")]
[InlineData("""{"mode":"Procedure"}""", "needs 'write.procedure'")]
[InlineData("""{"stampTimestamp":true}""", "no timestamp column")]
public void A_malformed_write_block_is_a_blocking_deploy_error_with_the_parsers_reason(string write, string reason)
=> DraftValidator.Validate(Draft("{}", Kv(write))).ShouldContain(e =>
e.Code == SqlTagDeployChecks.WriteBlockInvalidCode && e.Context == "t1" && e.Message.Contains(reason));
[Theory]
[InlineData("""{"mode":"Update"}""")]
[InlineData("""{"mode":"Insert"}""")]
[InlineData("""{"mode":"Upsert"}""")]
[InlineData("""{"mode":"Procedure","procedure":"dbo.SetSp","keyParam":"key"}""")]
public void Positive_control_valid_blocks_on_SqlServer_are_quiet(string write)
=> DraftValidator.Validate(Draft("""{"provider":"SqlServer"}""", Kv(write)))
.ShouldNotContain(e => e.Code.StartsWith("SqlWrite", StringComparison.Ordinal));
[Theory]
[InlineData("""{"mode":"Upsert"}""", true)]
[InlineData("""{"mode":"Procedure","procedure":"p"}""", true)]
[InlineData("""{"mode":"Insert"}""", false)]
[InlineData("""{"mode":"Update"}""", false)]
public void Odbc_serves_update_and_insert_only(string write, bool refused)
=> DraftValidator.Validate(Draft("""{"provider":"odbc"}""", Kv(write)))
.Any(e => e.Code == SqlTagDeployChecks.WriteModeUnsupportedCode && e.Context == "t1")
.ShouldBe(refused);
[Fact]
public void An_absent_provider_is_SqlServer_so_upsert_is_accepted()
=> DraftValidator.Validate(Draft("{}", Kv("""{"mode":"Upsert"}""")))
.ShouldNotContain(e => e.Code == SqlTagDeployChecks.WriteModeUnsupportedCode);
[Fact]
public void Tags_of_other_drivers_are_not_checked()
=> DraftValidator.Validate(Draft("{}", Kv("""{"mode":"Merge"}"""), driverType: "Modbus"))
.ShouldNotContain(e => e.Code == SqlTagDeployChecks.WriteBlockInvalidCode);
}