feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
allowWrites is no longer inert: SqlDriver implements IWritable, default off. - Gates (outermost first): node Writable + WriteOperate (server), driver allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean; anything else refuses the tag, and the deploy gate says why), and a model that names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and Query are BadNotWritable. - SqlWritePlanner: the only statement a write can become is UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the catalog-gate-canonical identifiers (dialect-quoted) with value and key bound; value-before-key marker order so positional ODBC binds correctly. No authored write SQL, INSERT, UPSERT or procedure. - Executed in a transaction that commits only when exactly one row was touched: 0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError, unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout; provider errors -> BadCommunicationError; health untouched. - Discovery advertises Operate only where a write could succeed. - AdminUI: driver form's Allow writes switch is live; tag editor gains a per-tag write switch; model validates writable strictly (parity corpus). - Factory no longer warns "allowWrites ignored"; it logs whether writes are on. - Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate, readback, absent key, two-row rollback, gate-rejected tag, discovery); MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback, server-side grant refusal, default-off). Seed adds a setpoints table and the one UPDATE grant. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -44,10 +44,10 @@ public sealed class SqlDriverConfigDto
|
||||
public bool? NullIsBad { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
|
||||
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
|
||||
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
|
||||
/// an operator who believes writes are enabled can.
|
||||
/// Master write switch (design §3.4, lmxopcua#753). Absent or <see langword="false"/> ⇒ every write is
|
||||
/// refused. <see langword="true"/> ⇒ a tag may be written when its own TagConfig also says
|
||||
/// <c>"writable": true</c> and its model names exactly one row; the node additionally needs its Writable
|
||||
/// flag and the caller the WriteOperate role.
|
||||
/// </summary>
|
||||
public bool? AllowWrites { get; init; }
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ public static class SqlEquipmentTagParser
|
||||
|
||||
var timestampColumn = ReadString(root, "timestampColumn");
|
||||
|
||||
// The per-tag write opt-in (design §3.4). Strict like the enums: absent ⇒ read-only, a JSON boolean ⇒
|
||||
// that, anything else (a "true" string, a number) ⇒ the tag is refused rather than guessed at — a
|
||||
// write permission is the last field to coerce leniently.
|
||||
var writable = false;
|
||||
if (root.TryGetProperty("writable", out var writableEl))
|
||||
{
|
||||
if (writableEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
|
||||
writable = writableEl.GetBoolean();
|
||||
}
|
||||
|
||||
if (model == SqlTagModel.Query)
|
||||
{
|
||||
// A named query reads no table of its own: the tag names the driver-config query, the result
|
||||
@@ -77,7 +87,7 @@ public static class SqlEquipmentTagParser
|
||||
Name: rawPath, Model: model, Table: string.Empty,
|
||||
TimestampColumn: timestampColumn, ColumnName: column,
|
||||
DeclaredType: declaredType, QueryName: queryName,
|
||||
RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue);
|
||||
RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue, Writable: writable);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -98,7 +108,7 @@ public static class SqlEquipmentTagParser
|
||||
def = new SqlTagDefinition(
|
||||
Name: rawPath, Model: model, Table: table!,
|
||||
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
|
||||
TimestampColumn: timestampColumn, DeclaredType: declaredType);
|
||||
TimestampColumn: timestampColumn, DeclaredType: declaredType, Writable: writable);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -122,7 +132,7 @@ public static class SqlEquipmentTagParser
|
||||
RowSelectorColumn: hasWherePair ? selectorColumn : null,
|
||||
RowSelectorValue: hasWherePair ? selectorValue : null,
|
||||
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
|
||||
DeclaredType: declaredType);
|
||||
DeclaredType: declaredType, Writable: writable);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
/// return exactly one row. Matched against the result set, never quoted into SQL.
|
||||
/// </param>
|
||||
/// <param name="RowKeyValue"><see cref="SqlTagModel.Query"/>: the <see cref="RowKeyColumn"/> value (matched as text).</param>
|
||||
/// <param name="Writable">
|
||||
/// The tag's own write opt-in (<c>"writable": true</c> in its TagConfig, design §3.4). Necessary but not
|
||||
/// sufficient: the driver also needs <c>allowWrites</c>, the model must name one row to update (KeyValue, or
|
||||
/// a WideRow where-pair), and the node needs its Writable flag plus the caller's WriteOperate role.
|
||||
/// </param>
|
||||
/// <param name="DeclaredType">
|
||||
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
|
||||
/// driver infers the type from the result set's column metadata.
|
||||
@@ -53,4 +58,5 @@ public sealed record SqlTagDefinition(
|
||||
DriverDataType? DeclaredType = null,
|
||||
string? QueryName = null,
|
||||
string? RowKeyColumn = null,
|
||||
string? RowKeyValue = null);
|
||||
string? RowKeyValue = null,
|
||||
bool Writable = false);
|
||||
|
||||
@@ -110,6 +110,12 @@ public static class SqlTagDefinitionFactory
|
||||
if (typeWarning is not null) warnings.Add(typeWarning);
|
||||
if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run
|
||||
|
||||
if (root.TryGetProperty("writable", out var writable)
|
||||
&& writable.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
warnings.Add("Sql TagConfig 'writable' must be a JSON boolean (true/false) — the driver will skip this tag.");
|
||||
}
|
||||
|
||||
var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue);
|
||||
if (model == SqlTagModel.Query)
|
||||
{
|
||||
|
||||
@@ -9,15 +9,19 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
|
||||
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
|
||||
/// The <c>Sql</c> driver instance: an Equipment-kind driver that polls SQL tables/views (and named read-only
|
||||
/// queries) and publishes selected cells as OPC UA variables — read-only unless writes are explicitly enabled. Structurally the SQL analogue of
|
||||
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
|
||||
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
|
||||
/// <see cref="SqlPollReader"/> owns the read path.
|
||||
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
|
||||
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
|
||||
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
|
||||
/// new capability, not a flag flip.</para>
|
||||
/// <para><b>Writes are opt-in at two levels and default off</b> (design §3.4, lmxopcua#753). A write needs
|
||||
/// the driver's <c>allowWrites</c>, the tag's own <c>"writable": true</c>, and a model that names one row
|
||||
/// (KeyValue, or a WideRow where-pair) — on top of the node's Writable flag and the caller's WriteOperate
|
||||
/// role, which the server checks first. It is a parameterised single-row <c>UPDATE</c> over the tag's
|
||||
/// catalog-validated identifiers (<see cref="SqlWritePlanner"/>), run in a transaction that commits only when
|
||||
/// exactly one row was touched. With <c>allowWrites</c> off every write is
|
||||
/// <see cref="SqlStatusCodes.BadNotWritable"/> and every variable discovers as
|
||||
/// <see cref="SecurityClassification.ViewOnly"/>.</para>
|
||||
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
|
||||
/// <see cref="IReadable"/> literally: it throws only when the database cannot be reached, and turns
|
||||
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
|
||||
@@ -28,7 +32,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// manufacture an exception to force backoff.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter,
|
||||
IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -532,8 +537,9 @@ public sealed class SqlDriver
|
||||
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
// v1 is read-only: no tag, and no configuration, can widen this.
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
// Operate only when a write could actually succeed — the driver switch, the tag's opt-in and a
|
||||
// single-row model all hold; otherwise the node advertises what it is: read-only.
|
||||
SecurityClass: IsWritePermitted(tag) ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
@@ -612,6 +618,131 @@ public sealed class SqlDriver
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable (design §3.4, lmxopcua#753) ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether a write to <paramref name="tag"/> is permitted by configuration: the driver's <c>allowWrites</c>,
|
||||
/// the tag's own <c>writable</c> opt-in, and a model with a single-row target. Says nothing about the node's
|
||||
/// Writable flag or the caller's role, which the server enforces before a write reaches the driver.
|
||||
/// </summary>
|
||||
private bool IsWritePermitted(SqlTagDefinition tag)
|
||||
=> _options.AllowWrites && tag.Writable
|
||||
&& (tag.Model == SqlTagModel.KeyValue
|
||||
|| (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null));
|
||||
|
||||
/// <inheritdoc />
|
||||
// Each write is a parameterised single-row UPDATE (SqlWritePlanner) over the tag's CATALOG-VALIDATED
|
||||
// definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no unvalidated identifier
|
||||
// can reach an UPDATE. It runs in a transaction that commits only when exactly one row was touched: zero rows
|
||||
// is BadNoData (nothing to update — the driver never inserts), more than one is BadConfigurationError (the
|
||||
// selector is ambiguous, the read-side rule), and both roll back. Bounded by operationTimeout like a poll. A
|
||||
// write never changes driver health: its outcome is the write's answer, not a statement about the database.
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var write = writes[i];
|
||||
results[i] = new WriteResult(await WriteOneAsync(write, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<uint> WriteOneAsync(WriteRequest write, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.AllowWrites) return SqlStatusCodes.BadNotWritable;
|
||||
if (write.FullReference is null || Lookup(write.FullReference) is not { } tag)
|
||||
return SqlStatusCodes.BadNodeIdUnknown;
|
||||
if (!IsWritePermitted(tag)) return SqlStatusCodes.BadNotWritable;
|
||||
|
||||
var planned = SqlWritePlanner.TryPlan(tag, write.Value, _dialect, out var plan);
|
||||
if (planned != SqlStatusCodes.Good) return planned;
|
||||
|
||||
try
|
||||
{
|
||||
return await RunBoundedAsync(
|
||||
token => ExecuteWriteAsync(tag, plan!, token),
|
||||
_options.OperationTimeout,
|
||||
"the write did not return",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning("Sql write to '{Tag}' timed out against {Endpoint}. Driver={DriverInstanceId}",
|
||||
tag.Name, Endpoint, _driverInstanceId);
|
||||
return SqlStatusCodes.BadTimeout;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller's write deadline fired; the uncommitted transaction dies with its connection.
|
||||
return SqlStatusCodes.BadTimeout;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Open or execute failed. Type only on any surface; the exception (never the connection string) to the
|
||||
// log sink. Nothing was committed.
|
||||
_logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type}. Driver={DriverInstanceId}",
|
||||
tag.Name, Endpoint, ex.GetType().Name, _driverInstanceId);
|
||||
return SqlStatusCodes.BadCommunicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.</summary>
|
||||
private async Task<uint> ExecuteWriteAsync(SqlTagDefinition tag, SqlWritePlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"the {_dialect.Provider} provider factory returned no connection.");
|
||||
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = _connectionString;
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (transaction.ConfigureAwait(false))
|
||||
{
|
||||
int rows;
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = plan.SqlText;
|
||||
command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
|
||||
for (var i = 0; i < plan.ParameterBindNames.Count; i++)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = plan.ParameterBindNames[i];
|
||||
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||
_dialect.ConfigureParameter(parameter, plan.Parameters[i]);
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rows == 1)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return SqlStatusCodes.Good;
|
||||
}
|
||||
|
||||
await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (rows == 0) return SqlStatusCodes.BadNoData;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Sql write to '{Tag}' matched {Rows} row(s) on '{Table}' and was rolled back: a write must update " +
|
||||
"exactly one row. Driver={DriverInstanceId}",
|
||||
tag.Name, rows, tag.Table, _driverInstanceId);
|
||||
return rows > 1 ? SqlStatusCodes.BadConfigurationError : SqlStatusCodes.BadNotSupported;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via the shared engine) ----
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -148,18 +148,6 @@ public static class SqlDriverFactoryExtensions
|
||||
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
||||
?? NullLogger.Instance;
|
||||
|
||||
if (dto.AllowWrites is true)
|
||||
{
|
||||
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
|
||||
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
|
||||
// on, however, can — so this is loud.
|
||||
logger.LogWarning(
|
||||
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
|
||||
"driver is read-only (it implements no write capability), so no configuration can enable a " +
|
||||
"write. Remove the flag, or expect reads only.",
|
||||
driverInstanceId);
|
||||
}
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId,
|
||||
@@ -176,9 +164,10 @@ public static class SqlDriverFactoryExtensions
|
||||
// permitted outside the provider call.
|
||||
logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
|
||||
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
|
||||
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}, writes {Writes}.",
|
||||
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
|
||||
options.DefaultPollInterval, options.OperationTimeout);
|
||||
options.DefaultPollInterval, options.OperationTimeout,
|
||||
options.AllowWrites ? "allowed for opted-in tags" : "off");
|
||||
|
||||
return driver;
|
||||
}
|
||||
@@ -273,6 +262,7 @@ public static class SqlDriverFactoryExtensions
|
||||
CommandTimeout = commandTimeout,
|
||||
MaxConcurrentGroups = maxConcurrentGroups,
|
||||
NullIsBad = dto.NullIsBad ?? false,
|
||||
AllowWrites = dto.AllowWrites ?? false,
|
||||
Queries = BuildQueries(dto, driverInstanceId, dialect),
|
||||
};
|
||||
}
|
||||
@@ -372,8 +362,8 @@ public static class SqlDriverFactoryExtensions
|
||||
/// <param name="Options">The typed driver options.</param>
|
||||
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
|
||||
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
|
||||
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
|
||||
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
|
||||
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry need not
|
||||
/// re-deserialize.</param>
|
||||
public sealed record SqlDriverBinding(
|
||||
SqlDriverOptions Options,
|
||||
ISqlDialect Dialect,
|
||||
|
||||
@@ -54,6 +54,14 @@ public sealed class SqlDriverOptions
|
||||
/// </summary>
|
||||
public bool NullIsBad { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The driver-level write switch (design §3.4, lmxopcua#753). <see langword="false"/> (the default) refuses
|
||||
/// every write with <see cref="SqlStatusCodes.BadNotWritable"/> whatever a tag says; <see langword="true"/>
|
||||
/// permits writes to tags that ALSO opted in (<see cref="SqlTagDefinition.Writable"/>) and whose model names
|
||||
/// one row (KeyValue, or a WideRow where-pair).
|
||||
/// </summary>
|
||||
public bool AllowWrites { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The validated named queries (design §5.4) a <see cref="SqlTagModel.Query"/> tag reads by name, keyed
|
||||
/// ordinally. Empty when the config declares none.
|
||||
|
||||
@@ -1021,7 +1021,7 @@ public sealed class SqlPollReader : IDisposable
|
||||
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
|
||||
/// type matches the type the address space declared for the node. Never throws.
|
||||
/// </summary>
|
||||
private static bool TryCoerce(object cell, DriverDataType target, out object? value)
|
||||
internal static bool TryCoerce(object cell, DriverDataType target, out object? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1251,6 +1251,15 @@ public static class SqlStatusCodes
|
||||
/// <summary>A defect in the reader itself; never expected in the field.</summary>
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
|
||||
/// <summary>
|
||||
/// A write the driver refuses by configuration: <c>allowWrites</c> is off, the tag did not opt in, or its
|
||||
/// model names no single row to update (lmxopcua#753).
|
||||
/// </summary>
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
|
||||
/// <summary>The provider could not report how many rows a write touched, so it was rolled back.</summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
|
||||
private const uint SeverityMask = 0xC0000000u;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// One compiled single-row write (lmxopcua#753): a parameterised <c>UPDATE</c> whose identifiers are the
|
||||
/// tag's catalog-validated ones and whose values are all bound.
|
||||
/// </summary>
|
||||
/// <param name="SqlText">The statement — dialect-quoted identifiers and parameter markers only.</param>
|
||||
/// <param name="ParameterBindNames">The bind names, in marker order (positional providers bind in this order).</param>
|
||||
/// <param name="Parameters">The bound values, aligned with <paramref name="ParameterBindNames"/>.</param>
|
||||
public sealed record SqlWritePlan(
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Turns one OPC UA write into a parameterised single-row <c>UPDATE</c> (design §3.4, lmxopcua#753) — pure, no
|
||||
/// I/O. <b>The write surface is deliberately narrow:</b> no authored SQL, no INSERT/UPSERT/procedure, only
|
||||
/// <c>UPDATE <table> SET <value column> = @v WHERE <key column> = @k</c> built from the
|
||||
/// tag's own identifiers.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SqlTagModel.KeyValue"/>: <c>SET valueColumn = @v WHERE keyColumn = @k</c>.</item>
|
||||
/// <item><see cref="SqlTagModel.WideRow"/> with a where-pair: <c>SET columnName = @v WHERE whereColumn = @w</c>.</item>
|
||||
/// <item><see cref="SqlTagModel.WideRow"/> by <c>topByTimestamp</c>, and <see cref="SqlTagModel.Query"/>:
|
||||
/// refused (<see cref="SqlStatusCodes.BadNotWritable"/>) — "the newest row" moves under a concurrent
|
||||
/// insert, and a named query's projection names no table to update.</item>
|
||||
/// </list>
|
||||
/// <para><b>Injection boundary.</b> Every identifier is one the catalog gate resolved and substituted (the
|
||||
/// driver plans writes only for <em>polled</em>, i.e. gate-accepted, definitions) and reaches the text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/>; the written value and the key are bound parameters. Nothing an
|
||||
/// OPC UA client sends reaches the command text.</para>
|
||||
/// </summary>
|
||||
public static class SqlWritePlanner
|
||||
{
|
||||
private const string ValueBaseName = "v";
|
||||
private const string KeyBaseName = "k";
|
||||
|
||||
/// <summary>
|
||||
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
|
||||
/// </summary>
|
||||
/// <param name="tag">The catalog-validated definition.</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="dialect">Quoting, markers and bind names.</param>
|
||||
/// <param name="plan">The plan, when this returns <see cref="SqlStatusCodes.Good"/>.</param>
|
||||
/// <returns><see cref="SqlStatusCodes.Good"/>, or the status the write publishes.</returns>
|
||||
public static uint TryPlan(SqlTagDefinition tag, object? value, ISqlDialect dialect, out SqlWritePlan? plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
plan = null;
|
||||
|
||||
string setColumn, keyColumn;
|
||||
string? keyValue;
|
||||
switch (tag.Model)
|
||||
{
|
||||
case SqlTagModel.KeyValue when !Blank(tag.ValueColumn) && !Blank(tag.KeyColumn) && tag.KeyValue is not null:
|
||||
setColumn = tag.ValueColumn!;
|
||||
keyColumn = tag.KeyColumn!;
|
||||
keyValue = tag.KeyValue;
|
||||
break;
|
||||
case SqlTagModel.WideRow when !Blank(tag.ColumnName) && !Blank(tag.RowSelectorColumn)
|
||||
&& tag.RowSelectorValue is not null:
|
||||
setColumn = tag.ColumnName!;
|
||||
keyColumn = tag.RowSelectorColumn!;
|
||||
keyValue = tag.RowSelectorValue;
|
||||
break;
|
||||
default:
|
||||
return SqlStatusCodes.BadNotWritable;
|
||||
}
|
||||
|
||||
if (!TryConvert(value, tag.DeclaredType, out var bound)) return SqlStatusCodes.BadTypeMismatch;
|
||||
|
||||
string sql;
|
||||
try
|
||||
{
|
||||
sql = string.Concat(
|
||||
"UPDATE ", QuoteQualifiedName(tag.Table, dialect),
|
||||
" SET ", dialect.QuoteIdentifier(setColumn), " = ", dialect.ParameterMarker(ValueBaseName),
|
||||
" WHERE ", dialect.QuoteIdentifier(keyColumn), " = ", dialect.ParameterMarker(KeyBaseName));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Unreachable for a gate-accepted definition (its identifiers came from the catalog); refuse rather
|
||||
// than build a statement around a name the dialect would not quote.
|
||||
return SqlStatusCodes.BadConfigurationError;
|
||||
}
|
||||
|
||||
// Marker order: SET's value first, then WHERE's key — a positional provider (ODBC) binds in this order.
|
||||
plan = new SqlWritePlan(
|
||||
sql,
|
||||
[dialect.ParameterBindName(ValueBaseName), dialect.ParameterBindName(KeyBaseName)],
|
||||
[bound, keyValue]);
|
||||
return SqlStatusCodes.Good;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the client's value to what is bound: coerced to the tag's declared <c>type</c> when it has one
|
||||
/// (the same coercion a read applies), otherwise passed through when it is a scalar a provider can bind.
|
||||
/// A null writes SQL NULL.
|
||||
/// </summary>
|
||||
private static bool TryConvert(object? value, DriverDataType? declared, out object? bound)
|
||||
{
|
||||
bound = null;
|
||||
if (value is null) return true;
|
||||
if (declared is { } type) return SqlPollReader.TryCoerce(value, type, out bound);
|
||||
|
||||
if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong
|
||||
or float or double or decimal or DateTime or DateTimeOffset or Guid)
|
||||
{
|
||||
bound = value is DateTime dt && dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.table</c> name.</summary>
|
||||
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
|
||||
=> string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
|
||||
|
||||
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
Reference in New Issue
Block a user