feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
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:
Joseph Doherty
2026-11-09 17:21:31 -05:00
parent 3c9ab4b668
commit 2331f7ed14
22 changed files with 916 additions and 75 deletions
@@ -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 &lt;table&gt; SET &lt;value column&gt; = @v WHERE &lt;key column&gt; = @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);
}