feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled
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
This commit is contained in:
@@ -30,8 +30,39 @@ public static class SqlEquipmentTagParser
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
|
||||
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
|
||||
=> TryParseDetailed(reference, rawPath, out def, out _);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TryParse"/>, also reporting why an otherwise-valid tag's
|
||||
/// <c>write</c> block refused it (lmxopcua#781). <paramref name="writeError"/> is null when the refusal — if
|
||||
/// any — came from the tag's own fields, or when the tag parsed.
|
||||
/// </summary>
|
||||
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath.</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <param name="writeError">The write block's rejection reason, when that is what refused the tag.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
|
||||
public static bool TryParseDetailed(string reference, string rawPath, out SqlTagDefinition def, out string? writeError)
|
||||
{
|
||||
writeError = null;
|
||||
if (!TryParseFields(reference, rawPath, out def, out var writeBlock)) return false;
|
||||
if (writeBlock is not { } block) return true;
|
||||
|
||||
if (!SqlWriteSpecParser.TryParse(block, def, out var spec, out writeError))
|
||||
{
|
||||
def = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
def = def with { Write = spec };
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The tag's own fields; the <c>write</c> block, when present, is handed back (cloned) unparsed.</summary>
|
||||
private static bool TryParseFields(string reference, string rawPath, out SqlTagDefinition def, out JsonElement? writeBlock)
|
||||
{
|
||||
def = null!;
|
||||
writeBlock = null;
|
||||
if (string.IsNullOrWhiteSpace(reference)) return false;
|
||||
if (reference.TrimStart().FirstOrDefault() != '{') return false;
|
||||
try
|
||||
@@ -55,6 +86,7 @@ public static class SqlEquipmentTagParser
|
||||
: null;
|
||||
|
||||
var timestampColumn = ReadString(root, "timestampColumn");
|
||||
if (root.TryGetProperty("write", out var writeEl)) writeBlock = writeEl.Clone();
|
||||
|
||||
// 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
|
||||
|
||||
@@ -43,6 +43,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="Write">
|
||||
/// The tag's optional <c>write</c> block (lmxopcua#781 item 2): the statement shape a write becomes. Null ⇒ the
|
||||
/// default single-row UPDATE (<see cref="SqlWriteSpec.Default"/>). Meaningful only when <paramref name="Writable"/>.
|
||||
/// </param>
|
||||
public sealed record SqlTagDefinition(
|
||||
string Name,
|
||||
SqlTagModel Model,
|
||||
@@ -59,4 +63,9 @@ public sealed record SqlTagDefinition(
|
||||
string? QueryName = null,
|
||||
string? RowKeyColumn = null,
|
||||
string? RowKeyValue = null,
|
||||
bool Writable = false);
|
||||
bool Writable = false,
|
||||
SqlWriteSpec? Write = null)
|
||||
{
|
||||
/// <summary>The effective write spec — the authored block, or the default UPDATE.</summary>
|
||||
public SqlWriteSpec EffectiveWrite => Write ?? SqlWriteSpec.Default;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,14 @@ public static class SqlTagDefinitionFactory
|
||||
return warnings;
|
||||
|
||||
// The accept gate: whatever the driver serves draws nothing. See the class remarks.
|
||||
if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings;
|
||||
if (SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError)) return warnings;
|
||||
|
||||
// The tag's own fields parsed and its write block is what refused it: the parser said why.
|
||||
if (writeError is not null)
|
||||
{
|
||||
warnings.Add(FormatWriteError(writeError));
|
||||
return warnings;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -83,6 +90,25 @@ public static class SqlTagDefinitionFactory
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Why the blob's <c>write</c> block (lmxopcua#781 item 2) refuses an otherwise-valid tag, or null when the
|
||||
/// block is valid, absent, or the tag's own fields already refuse it (which <see cref="Inspect"/> reports).
|
||||
/// The verdict is the runtime parser's own (<see cref="SqlEquipmentTagParser.TryParseDetailed"/>);
|
||||
/// the deploy gate turns it into a blocking error. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The operator-facing message, or null.</returns>
|
||||
public static string? DescribeWriteBlock(string reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference)) return null;
|
||||
return SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError) || writeError is null
|
||||
? null
|
||||
: FormatWriteError(writeError);
|
||||
}
|
||||
|
||||
private static string FormatWriteError(string writeError)
|
||||
=> $"Sql TagConfig 'write' block is invalid: {writeError} — the driver will skip this tag (BadNodeIdUnknown).";
|
||||
|
||||
/// <summary>
|
||||
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
|
||||
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>The statement shape a Sql tag's write becomes (design §3.4, lmxopcua#781 item 2).</summary>
|
||||
public enum SqlWriteMode
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>UPDATE … SET value WHERE key</c> on exactly one existing row — the default, and the only shape before
|
||||
/// #781. Zero rows is <c>BadNoData</c>; the driver never inserts in this mode.
|
||||
/// </summary>
|
||||
Update,
|
||||
|
||||
/// <summary><c>INSERT</c> of one new row — the key (when the model has one), the value and, optionally, the stamp.</summary>
|
||||
Insert,
|
||||
|
||||
/// <summary>
|
||||
/// Update the keyed row, or insert it when absent, in one statement (SQL Server / Oracle <c>MERGE</c>,
|
||||
/// PostgreSQL <c>INSERT … ON CONFLICT</c>, MySQL <c>INSERT … ON DUPLICATE KEY UPDATE</c>). Not on Odbc.
|
||||
/// </summary>
|
||||
Upsert,
|
||||
|
||||
/// <summary>A call of one named, catalog-validated stored procedure with the value (and optionally key / stamp) bound.</summary>
|
||||
Procedure,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The parsed form of a tag's optional <c>write</c> block. Every string is authored and NEVER reaches SQL text
|
||||
/// unvalidated: <see cref="Procedure"/> is resolved against the live routine catalog by the catalog gate and
|
||||
/// dialect-quoted, and the parameter names are restricted to a plain identifier charset by the parser
|
||||
/// (<see cref="SqlWriteSpecParser"/>). Values are always bound.
|
||||
/// </summary>
|
||||
/// <param name="Mode">The statement shape.</param>
|
||||
/// <param name="StampTimestamp">
|
||||
/// Update / Insert / Upsert: also set the tag's timestamp column (<c>timestampColumn</c>, or a WideRow's
|
||||
/// <c>topByTimestamp</c>) to the write time, in UTC.
|
||||
/// </param>
|
||||
/// <param name="Procedure">Procedure: the <c>procedure</c> or <c>schema.procedure</c> to call.</param>
|
||||
/// <param name="ValueParameter">Procedure: the procedure parameter the written value binds to (default <c>value</c>).</param>
|
||||
/// <param name="KeyParameter">Procedure: the parameter the tag's key binds to; null ⇒ the key is not passed.</param>
|
||||
/// <param name="TimestampParameter">Procedure: the parameter the write time (UTC) binds to; null ⇒ not passed.</param>
|
||||
public sealed record SqlWriteSpec(
|
||||
SqlWriteMode Mode,
|
||||
bool StampTimestamp = false,
|
||||
string? Procedure = null,
|
||||
string? ValueParameter = null,
|
||||
string? KeyParameter = null,
|
||||
string? TimestampParameter = null)
|
||||
{
|
||||
/// <summary>The default when a writable tag carries no <c>write</c> block: today's single-row UPDATE.</summary>
|
||||
public static SqlWriteSpec Default { get; } = new(SqlWriteMode.Update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which <see cref="SqlWriteMode"/>s each provider serves. One table, read by the dialects (runtime), the deploy
|
||||
/// gate (<c>SqlTagDeployChecks</c>) and the docs, so what deploy accepts is what the driver serves.
|
||||
/// <para><b>Odbc is Update / Insert only.</b> The ANSI profile has no upsert every ODBC backend accepts
|
||||
/// (<c>MERGE</c> is missing or partial on several), and positional <c>?</c> markers cannot be reused, which every
|
||||
/// single-statement upsert needs; a stored-procedure call would need a routine catalog and calling convention per
|
||||
/// backend. Use the backend's native provider for those modes.</para>
|
||||
/// </summary>
|
||||
public static class SqlWriteSupport
|
||||
{
|
||||
/// <summary>True when <paramref name="provider"/> serves <paramref name="mode"/>.</summary>
|
||||
/// <param name="provider">The driver's provider.</param>
|
||||
/// <param name="mode">The tag's write mode.</param>
|
||||
/// <returns>Whether the driver will execute the mode on that provider.</returns>
|
||||
public static bool IsSupported(SqlProvider provider, SqlWriteMode mode) => mode switch
|
||||
{
|
||||
SqlWriteMode.Update or SqlWriteMode.Insert => true,
|
||||
SqlWriteMode.Upsert or SqlWriteMode.Procedure => provider != SqlProvider.Odbc,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses and validates a tag's <c>write</c> block against the tag it belongs to. Used by
|
||||
/// <see cref="SqlEquipmentTagParser.TryParse"/> (a failure refuses the whole tag) and — through
|
||||
/// <see cref="SqlTagDefinitionFactory.DescribeWriteBlock"/> — by the deploy gate and the AdminUI, so all three
|
||||
/// share one verdict and one explanation.
|
||||
/// </summary>
|
||||
public static partial class SqlWriteSpecParser
|
||||
{
|
||||
/// <summary>The keys a <c>write</c> block may carry. Anything else is refused — a typo in a write permission is
|
||||
/// the last place to guess.</summary>
|
||||
private static readonly string[] KnownKeys =
|
||||
["mode", "stampTimestamp", "procedure", "valueParam", "keyParam", "timestampParam"];
|
||||
|
||||
private static readonly string[] ProcedureOnlyKeys = ["procedure", "valueParam", "keyParam", "timestampParam"];
|
||||
|
||||
/// <summary>The default procedure parameter the value binds to.</summary>
|
||||
public const string DefaultValueParameter = "value";
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="write"/> for <paramref name="tag"/> (whose own fields are already parsed).
|
||||
/// </summary>
|
||||
/// <param name="write">The <c>write</c> property's value.</param>
|
||||
/// <param name="tag">The tag definition the block belongs to.</param>
|
||||
/// <param name="spec">The spec, when this returns <see langword="true"/>.</param>
|
||||
/// <param name="error">An operator-facing reason, when this returns <see langword="false"/>.</param>
|
||||
/// <returns>Whether the block is valid for the tag.</returns>
|
||||
public static bool TryParse(JsonElement write, SqlTagDefinition tag, out SqlWriteSpec? spec, out string? error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
spec = null;
|
||||
error = null;
|
||||
|
||||
if (write.ValueKind != JsonValueKind.Object)
|
||||
return Fail("'write' must be a JSON object", out error);
|
||||
|
||||
foreach (var property in write.EnumerateObject())
|
||||
{
|
||||
if (!KnownKeys.Contains(property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
return Fail(
|
||||
$"'write' has an unknown key '{property.Name}' (valid: {string.Join(", ", KnownKeys)})", out error);
|
||||
}
|
||||
}
|
||||
|
||||
var mode = SqlWriteMode.Update;
|
||||
if (write.TryGetProperty("mode", out var modeEl))
|
||||
{
|
||||
if (modeEl.ValueKind != JsonValueKind.String
|
||||
|| TagConfigJson.ClassifyEnumText(modeEl.GetString(), out mode) != JsonEnumRead.Valid)
|
||||
{
|
||||
return Fail(
|
||||
$"'write.mode' must be one of {string.Join(", ", Enum.GetNames<SqlWriteMode>())}", out error);
|
||||
}
|
||||
}
|
||||
|
||||
var stamp = false;
|
||||
if (write.TryGetProperty("stampTimestamp", out var stampEl))
|
||||
{
|
||||
if (stampEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
return Fail("'write.stampTimestamp' must be a JSON boolean", out error);
|
||||
stamp = stampEl.GetBoolean();
|
||||
}
|
||||
|
||||
if (mode != SqlWriteMode.Procedure)
|
||||
{
|
||||
foreach (var key in ProcedureOnlyKeys)
|
||||
{
|
||||
if (write.TryGetProperty(key, out _))
|
||||
return Fail($"'write.{key}' applies only to mode Procedure (this block's mode is {mode})", out error);
|
||||
}
|
||||
|
||||
if (tag.Model == SqlTagModel.Query)
|
||||
{
|
||||
return Fail(
|
||||
$"a Query tag reads a named query, which names no table to {mode.ToString().ToLowerInvariant()}; " +
|
||||
"only mode Procedure can write a Query tag", out error);
|
||||
}
|
||||
|
||||
var hasWherePair = tag.Model == SqlTagModel.KeyValue || tag.RowSelectorColumn is not null;
|
||||
if (mode is SqlWriteMode.Update or SqlWriteMode.Upsert && !hasWherePair)
|
||||
{
|
||||
return Fail(
|
||||
$"mode {mode} needs a row key — a KeyValue tag, or a WideRow 'whereColumn'/'whereValue' pair; " +
|
||||
"a 'topByTimestamp' row moves under a concurrent insert (use Insert or Procedure)", out error);
|
||||
}
|
||||
|
||||
if (stamp && StampColumn(tag) is null)
|
||||
{
|
||||
return Fail(
|
||||
"'write.stampTimestamp' is true but the tag has no timestamp column to stamp — author " +
|
||||
"'timestampColumn' (or a WideRow 'topByTimestamp')", out error);
|
||||
}
|
||||
|
||||
spec = new SqlWriteSpec(mode, stamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Procedure ----
|
||||
if (stamp)
|
||||
return Fail("'write.stampTimestamp' does not apply to mode Procedure — bind the write time with 'timestampParam'", out error);
|
||||
|
||||
if (!write.TryGetProperty("procedure", out var procEl) || procEl.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(procEl.GetString()))
|
||||
{
|
||||
return Fail("mode Procedure needs 'write.procedure' — the procedure or schema.procedure to call", out error);
|
||||
}
|
||||
|
||||
var procedure = procEl.GetString()!.Trim();
|
||||
if (procedure.Split('.').Length > 2)
|
||||
{
|
||||
return Fail(
|
||||
"'write.procedure' may be 'procedure' or 'schema.procedure' only — a package, cross-database or " +
|
||||
"linked-server name cannot be validated against this connection's catalog", out error);
|
||||
}
|
||||
|
||||
if (!TryReadParameter(write, "valueParam", DefaultValueParameter, out var valueParam, out error)) return false;
|
||||
if (!TryReadParameter(write, "keyParam", null, out var keyParam, out error)) return false;
|
||||
if (!TryReadParameter(write, "timestampParam", null, out var timestampParam, out error)) return false;
|
||||
|
||||
if (keyParam is not null && KeyValue(tag) is null)
|
||||
{
|
||||
return Fail(
|
||||
"'write.keyParam' is set but the tag has no key to bind — a KeyValue 'keyValue', a WideRow " +
|
||||
"'whereValue' or a Query 'rowKey.value'", out error);
|
||||
}
|
||||
|
||||
var names = new[] { valueParam, keyParam, timestampParam }.Where(n => n is not null).ToArray();
|
||||
if (names.Distinct(StringComparer.OrdinalIgnoreCase).Count() != names.Length)
|
||||
return Fail("'write' binds two values to the same procedure parameter", out error);
|
||||
|
||||
spec = new SqlWriteSpec(SqlWriteMode.Procedure, false, procedure, valueParam, keyParam, timestampParam);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The column a <c>stampTimestamp</c> write sets: <c>timestampColumn</c>, else a WideRow's <c>topByTimestamp</c>.</summary>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <returns>The column, or null when the tag has none.</returns>
|
||||
public static string? StampColumn(SqlTagDefinition tag)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
if (tag.Model == SqlTagModel.Query) return null;
|
||||
if (!string.IsNullOrWhiteSpace(tag.TimestampColumn)) return tag.TimestampColumn;
|
||||
return tag.Model == SqlTagModel.WideRow && !string.IsNullOrWhiteSpace(tag.RowSelectorTopByTimestamp)
|
||||
? tag.RowSelectorTopByTimestamp
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The tag's key value — what a procedure's <c>keyParam</c> binds: keyValue, whereValue or rowKey.value.</summary>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <returns>The key, or null when the model names none.</returns>
|
||||
public static string? KeyValue(SqlTagDefinition tag)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
return tag.Model switch
|
||||
{
|
||||
SqlTagModel.KeyValue => tag.KeyValue,
|
||||
SqlTagModel.WideRow => tag.RowSelectorValue,
|
||||
SqlTagModel.Query => tag.RowKeyColumn is null ? null : tag.RowKeyValue,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a procedure parameter name: absent ⇒ <paramref name="fallback"/>; otherwise a plain identifier
|
||||
/// (letter or underscore, then letters, digits, underscores; at most 128 characters), one leading <c>@</c> or
|
||||
/// <c>:</c> tolerated and stripped. The name reaches SQL text unquoted (named notation), so this charset is
|
||||
/// the injection boundary for it.
|
||||
/// </summary>
|
||||
private static bool TryReadParameter(
|
||||
JsonElement write, string key, string? fallback, out string? name, out string? error)
|
||||
{
|
||||
name = fallback;
|
||||
error = null;
|
||||
if (!write.TryGetProperty(key, out var el)) return true;
|
||||
if (el.ValueKind != JsonValueKind.String) return Fail($"'write.{key}' must be a string", out error);
|
||||
|
||||
var text = el.GetString()!.Trim();
|
||||
if (text.Length > 0 && text[0] is '@' or ':') text = text[1..];
|
||||
if (!ParameterName().IsMatch(text))
|
||||
{
|
||||
return Fail(
|
||||
$"'write.{key}' must be a plain parameter name — a letter or underscore, then letters, digits or " +
|
||||
"underscores (at most 128)", out error);
|
||||
}
|
||||
|
||||
name = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Fail(string message, out string? error)
|
||||
{
|
||||
error = message;
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ParameterName();
|
||||
}
|
||||
@@ -243,4 +243,65 @@ public interface ISqlDialect
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
DriverDataType MapColumnType(string sqlDataType);
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether this dialect executes <paramref name="mode"/>. Defaults to the shared
|
||||
/// <see cref="SqlWriteSupport"/> table for <see cref="Provider"/> — the same table the deploy gate checks, so
|
||||
/// a mode deploy accepts is a mode the driver serves.
|
||||
/// </summary>
|
||||
/// <param name="mode">The tag's write mode.</param>
|
||||
/// <returns>Whether a write in that mode is executed.</returns>
|
||||
bool SupportsWriteMode(SqlWriteMode mode) => SqlWriteSupport.IsSupported(Provider, mode);
|
||||
|
||||
/// <summary>
|
||||
/// The single-statement upsert of one keyed row, or null when this dialect has none. Every argument is
|
||||
/// already dialect-quoted (identifiers) or a parameter marker — the dialect only arranges them, so nothing
|
||||
/// authored and nothing a client sent reaches the text through here. A marker may appear more than once in
|
||||
/// the result, so a positional-marker dialect must return null.
|
||||
/// </summary>
|
||||
/// <param name="quotedTable">The quoted, catalog-canonical target table.</param>
|
||||
/// <param name="quotedKeyColumn">The quoted key column.</param>
|
||||
/// <param name="keyMarker">The key's parameter marker.</param>
|
||||
/// <param name="assignments">The quoted columns to set and their markers — the value first, then any stamp.</param>
|
||||
/// <returns>The statement, or null when the dialect cannot upsert.</returns>
|
||||
string? UpsertSql(
|
||||
string quotedTable, string quotedKeyColumn, string keyMarker,
|
||||
IReadOnlyList<(string QuotedColumn, string Marker)> assignments) => null;
|
||||
|
||||
/// <summary>
|
||||
/// The most rows an upsert may report touching for one key. 1 everywhere except MySQL, whose
|
||||
/// <c>ON DUPLICATE KEY UPDATE</c> reports 2 for "updated an existing row".
|
||||
/// </summary>
|
||||
int UpsertMaxRowsAffected => 1;
|
||||
|
||||
/// <summary>
|
||||
/// The statement that calls one stored procedure, or null when this dialect cannot. Arguments are passed by
|
||||
/// name (<c>EXEC p @a = @v</c>, <c>CALL p(a => @v)</c>, <c>BEGIN p(a => :p_v); END;</c>) except where the
|
||||
/// backend has no named notation (MySQL: positional, in list order). Parameter names reach the text
|
||||
/// unquoted; the parser restricts them to a plain identifier charset.
|
||||
/// </summary>
|
||||
/// <param name="quotedProcedure">The quoted, catalog-canonical procedure name.</param>
|
||||
/// <param name="arguments">Procedure parameter name → marker, in order.</param>
|
||||
/// <returns>The statement, or null.</returns>
|
||||
string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
|
||||
=> null;
|
||||
|
||||
/// <summary>
|
||||
/// Catalog query listing the stored procedures in one schema (column <c>ROUTINE_NAME</c>, bound like
|
||||
/// <see cref="ListTablesSql"/> under the base name <c>schema</c>), or null when this dialect has no routine
|
||||
/// catalog — the catalog gate then refuses every Procedure-mode tag rather than quote an unvalidated name.
|
||||
/// </summary>
|
||||
string? ListRoutinesSql => null;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a write's provider exception so a constraint the database enforced reads as an authoring /
|
||||
/// data fault rather than "the database is unreachable". Must never throw. The default reads the ANSI
|
||||
/// <see cref="DbException.SqlState"/> when the provider reports one.
|
||||
/// </summary>
|
||||
/// <param name="exception">What the write threw.</param>
|
||||
/// <returns>The failure class.</returns>
|
||||
SqlWriteFailure ClassifyWriteFailure(Exception exception)
|
||||
=> SqlWriteFailures.FromSqlState((exception as DbException)?.SqlState);
|
||||
}
|
||||
|
||||
@@ -177,4 +177,48 @@ public sealed class MySqlDialect : ISqlDialect
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
|
||||
/// <inheritdoc />
|
||||
// INSERT … ON DUPLICATE KEY UPDATE — fires on ANY unique key of the table, so the key column must be the table's
|
||||
// primary key or a unique key (and the table should carry no OTHER unique key a write could collide with).
|
||||
// MySqlConnector substitutes parameters client-side, so a marker may repeat.
|
||||
public string? UpsertSql(
|
||||
string quotedTable, string quotedKeyColumn, string keyMarker,
|
||||
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
|
||||
=> string.Concat(
|
||||
"INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
|
||||
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
|
||||
") ON DUPLICATE KEY UPDATE ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)));
|
||||
|
||||
/// <inheritdoc />
|
||||
// 1 for an inserted row, 2 for an updated one (MySQL's documented ON DUPLICATE KEY UPDATE count). MySqlConnector
|
||||
// defaults to found-rows (UseAffectedRows=false), so an update to the same value still reports a row, not 0.
|
||||
public int UpsertMaxRowsAffected => 2;
|
||||
|
||||
/// <inheritdoc />
|
||||
// MySQL has no named-argument notation: arguments are POSITIONAL, in the order the driver lists them — the value,
|
||||
// then the key (keyParam), then the write time (timestampParam). The parameter names are not sent.
|
||||
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
|
||||
=> "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Marker)) + ")";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? ListRoutinesSql =>
|
||||
"SELECT ROUTINE_NAME AS ROUTINE_NAME FROM information_schema.ROUTINES " +
|
||||
"WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
|
||||
|
||||
/// <inheritdoc />
|
||||
// 1062 duplicate entry; 3819 CHECK violated (reported as SQLSTATE HY000, so it needs the number); the rest by
|
||||
// SQLSTATE — 23000 covers NOT NULL (1048) and foreign keys (1451/1452), class 22 overflow/truncation.
|
||||
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
|
||||
{
|
||||
if (exception is not MySqlException my) return SqlWriteFailure.Other;
|
||||
return my.Number switch
|
||||
{
|
||||
1062 => SqlWriteFailure.UniqueViolation,
|
||||
3819 => SqlWriteFailure.IntegrityViolation,
|
||||
_ => SqlWriteFailures.FromSqlState(my.SqlState),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,4 +170,15 @@ public sealed class OdbcDialect : ISqlDialect
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
// Update and Insert only (SqlWriteSupport): no Upsert (positional markers cannot repeat, and MERGE is missing or
|
||||
// partial on several ODBC backends) and no Procedure (no portable routine catalog or calling convention).
|
||||
|
||||
/// <inheritdoc />
|
||||
// The driver manager reports the backend's SQLSTATE on each OdbcError.
|
||||
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
|
||||
=> exception is OdbcException odbc && odbc.Errors.Count > 0
|
||||
? SqlWriteFailures.FromSqlState(odbc.Errors[0].SQLState)
|
||||
: SqlWriteFailure.Other;
|
||||
}
|
||||
|
||||
@@ -325,4 +325,50 @@ public sealed class OracleDialect : ISqlDialect
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
|
||||
/// <inheritdoc />
|
||||
// MERGE from a constant one-row DUAL source, matching the target's key to the bound key; BindByName (set in
|
||||
// ConfigureCommand) is what lets the key and value markers appear twice. No trailing semicolon — ODP.NET refuses
|
||||
// one on a SQL statement.
|
||||
public string? UpsertSql(
|
||||
string quotedTable, string quotedKeyColumn, string keyMarker,
|
||||
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
|
||||
=> string.Concat(
|
||||
"MERGE INTO ", quotedTable, " tgt USING (SELECT 1 AS one FROM dual) src ON (tgt.", quotedKeyColumn, " = ",
|
||||
keyMarker, ") WHEN MATCHED THEN UPDATE SET ",
|
||||
string.Join(", ", assignments.Select(a => "tgt." + a.QuotedColumn + " = " + a.Marker)),
|
||||
" WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
|
||||
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ")");
|
||||
|
||||
/// <inheritdoc />
|
||||
// An anonymous PL/SQL block with named notation. Unquoted parameter names fold to upper case, as Oracle's own do.
|
||||
// A standalone procedure only — a package member (pkg.proc) is a three-part name the gate cannot validate.
|
||||
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
|
||||
=> "BEGIN " + quotedProcedure
|
||||
+ (arguments.Count == 0 ? string.Empty : "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")")
|
||||
+ "; END;";
|
||||
|
||||
/// <inheritdoc />
|
||||
// ALL_PROCEDURES lists a standalone procedure as OBJECT_TYPE 'PROCEDURE' with OBJECT_NAME its name; it shows only
|
||||
// what the principal may execute or owns.
|
||||
public string? ListRoutinesSql =>
|
||||
"SELECT object_name AS ROUTINE_NAME FROM ALL_PROCEDURES " +
|
||||
"WHERE owner = " + ParameterMarker("schema") + " AND object_type = 'PROCEDURE' ORDER BY object_name";
|
||||
|
||||
/// <inheritdoc />
|
||||
// ODP.NET reports no SQLSTATE; the ORA- number decides. 1 unique; 1400/1407 NULL, 2290 CHECK, 2291/2292 FK;
|
||||
// 1438 precision, 12899 too large, 1722 invalid number, 18xx date conversion.
|
||||
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
|
||||
{
|
||||
if (exception is not OracleException ora) return SqlWriteFailure.Other;
|
||||
return ora.Number switch
|
||||
{
|
||||
1 => SqlWriteFailure.UniqueViolation,
|
||||
1400 or 1407 or 2290 or 2291 or 2292 => SqlWriteFailure.IntegrityViolation,
|
||||
1438 or 12899 or 1722 or 1830 or 1840 or 1841 or 1843 or 1847 or 1858 or 1861 => SqlWriteFailure.DataException,
|
||||
_ => SqlWriteFailure.Other,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,4 +267,35 @@ public sealed class PostgresDialect : ISqlDialect
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
|
||||
/// <inheritdoc />
|
||||
// INSERT … ON CONFLICT (key) DO UPDATE — atomic under concurrency, but it REQUIRES a unique constraint or unique
|
||||
// index on exactly the key column (the server refuses the statement otherwise: SQLSTATE 42P10, which surfaces as
|
||||
// BadCommunicationError). EXCLUDED is the row the insert proposed, so each value marker appears once in VALUES.
|
||||
// Because a conflict target is unique, the statement touches exactly one row.
|
||||
public string? UpsertSql(
|
||||
string quotedTable, string quotedKeyColumn, string keyMarker,
|
||||
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
|
||||
=> string.Concat(
|
||||
"INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
|
||||
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
|
||||
") ON CONFLICT (", quotedKeyColumn, ") DO UPDATE SET ",
|
||||
string.Join(", ", assignments.Select(a => a.QuotedColumn + " = EXCLUDED." + a.QuotedColumn)));
|
||||
|
||||
/// <inheritdoc />
|
||||
// CALL with named notation (PostgreSQL 11+ procedures). Argument types must resolve by IMPLICIT cast: a Float64
|
||||
// binds as double precision, so declare a numeric parameter as double precision (or cast inside the procedure),
|
||||
// and a write time binds as timestamptz.
|
||||
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
|
||||
=> "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")";
|
||||
|
||||
/// <inheritdoc />
|
||||
// information_schema.routines shows only routines the current role may execute or owns — the gate's question.
|
||||
public string? ListRoutinesSql =>
|
||||
"SELECT routine_name AS \"ROUTINE_NAME\" FROM information_schema.routines " +
|
||||
"WHERE routine_schema = @schema AND routine_type = 'PROCEDURE' ORDER BY \"ROUTINE_NAME\"";
|
||||
|
||||
// ClassifyWriteFailure: the interface default reads PostgresException.SqlState, which Npgsql always reports.
|
||||
}
|
||||
|
||||
@@ -222,4 +222,52 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- writes (lmxopcua#781 item 2) ----
|
||||
|
||||
/// <inheritdoc />
|
||||
// MERGE … WITH (HOLDLOCK): the key-range lock HOLDLOCK takes is what makes the matched/not-matched decision and
|
||||
// the insert one atomic step under concurrency — a bare MERGE (or UPDATE-then-INSERT) can race two writers into a
|
||||
// duplicate insert. The source is a constant one-row derived table and the ON clause compares the target's key to
|
||||
// the bound key directly, so a key matching several rows updates them all and the driver's row-count rule rolls it
|
||||
// back. The trailing semicolon is mandatory for MERGE.
|
||||
public string? UpsertSql(
|
||||
string quotedTable, string quotedKeyColumn, string keyMarker,
|
||||
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
|
||||
=> string.Concat(
|
||||
"MERGE INTO ", quotedTable, " WITH (HOLDLOCK) AS tgt USING (SELECT 1 AS one) AS src ON tgt.",
|
||||
quotedKeyColumn, " = ", keyMarker,
|
||||
" WHEN MATCHED THEN UPDATE SET ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)),
|
||||
" WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
|
||||
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ");");
|
||||
|
||||
/// <inheritdoc />
|
||||
// EXEC with named arguments: the left-hand @name is the procedure's own parameter, the right-hand marker the bind.
|
||||
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
|
||||
=> arguments.Count == 0
|
||||
? "EXEC " + quotedProcedure
|
||||
: "EXEC " + quotedProcedure + " " + string.Join(", ", arguments.Select(a => "@" + a.Parameter + " = " + a.Marker));
|
||||
|
||||
/// <inheritdoc />
|
||||
// INFORMATION_SCHEMA.ROUTINES lists only routines the principal may see (it holds a permission on), which is the
|
||||
// question the gate asks. Procedures only — a function is not callable with EXEC semantics here.
|
||||
public string? ListRoutinesSql =>
|
||||
"SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES " +
|
||||
"WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
|
||||
|
||||
/// <inheritdoc />
|
||||
// SqlClient reports no SQLSTATE, so the engine error number decides. 2627/2601 duplicate key (constraint /
|
||||
// unique index); 547 FK or CHECK, 515 NULL into NOT NULL; 8115/220/232 arithmetic overflow, 8152/2628 truncation,
|
||||
// 245/8114 conversion, 241/242 date conversion or out of range.
|
||||
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
|
||||
{
|
||||
if (exception is not SqlException sql) return SqlWriteFailure.Other;
|
||||
return sql.Number switch
|
||||
{
|
||||
2627 or 2601 => SqlWriteFailure.UniqueViolation,
|
||||
547 or 515 => SqlWriteFailure.IntegrityViolation,
|
||||
8115 or 220 or 232 or 8152 or 2628 or 245 or 8114 or 241 or 242 => SqlWriteFailure.DataException,
|
||||
_ => SqlWriteFailure.Other,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>Why a write's statement failed, as far as the provider's error says (lmxopcua#781 item 2).</summary>
|
||||
public enum SqlWriteFailure
|
||||
{
|
||||
/// <summary>Anything else — permission denied, a missing object, a broken connection. <c>BadCommunicationError</c>.</summary>
|
||||
Other,
|
||||
|
||||
/// <summary>A unique / primary-key violation (SQLSTATE 23505, SQL Server 2627/2601, MySQL 1062, ORA-00001). <c>BadEntryExists</c>.</summary>
|
||||
UniqueViolation,
|
||||
|
||||
/// <summary>Another integrity constraint — NOT NULL, foreign key, CHECK (SQLSTATE class 23). <c>BadOutOfRange</c>.</summary>
|
||||
IntegrityViolation,
|
||||
|
||||
/// <summary>A data exception — overflow, truncation, an unconvertible value (SQLSTATE class 22). <c>BadOutOfRange</c>.</summary>
|
||||
DataException,
|
||||
}
|
||||
|
||||
/// <summary>SQLSTATE-based classification shared by the dialects.</summary>
|
||||
public static class SqlWriteFailures
|
||||
{
|
||||
/// <summary>Classifies an ANSI SQLSTATE: 23505 unique, class 23 integrity, class 22 data; anything else (or null) other.</summary>
|
||||
/// <param name="sqlState">The five-character SQLSTATE, or null.</param>
|
||||
/// <returns>The failure class.</returns>
|
||||
public static SqlWriteFailure FromSqlState(string? sqlState)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sqlState) || sqlState.Length < 2) return SqlWriteFailure.Other;
|
||||
if (sqlState == "23505") return SqlWriteFailure.UniqueViolation;
|
||||
if (sqlState.StartsWith("23", StringComparison.Ordinal)) return SqlWriteFailure.IntegrityViolation;
|
||||
if (sqlState.StartsWith("22", StringComparison.Ordinal)) return SqlWriteFailure.DataException;
|
||||
return SqlWriteFailure.Other;
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,23 @@ public sealed class SqlCatalog
|
||||
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
|
||||
|
||||
/// <summary>Stored procedures per canonical schema — loaded only for schemas a Procedure-mode tag names (lmxopcua#781).</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _routinesBySchema;
|
||||
|
||||
/// <summary>Constructs a catalog snapshot.</summary>
|
||||
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
|
||||
/// <param name="schemas">Every schema the connection can see.</param>
|
||||
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
|
||||
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
|
||||
/// <param name="routinesBySchema">Canonical schema → its stored procedures; null ⇒ none loaded.</param>
|
||||
internal SqlCatalog(
|
||||
string defaultSchema,
|
||||
IReadOnlyList<string> schemas,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>>? routinesBySchema = null)
|
||||
{
|
||||
_routinesBySchema = routinesBySchema ?? new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
DefaultSchema = defaultSchema;
|
||||
_schemas = schemas;
|
||||
_tablesBySchema = tablesBySchema;
|
||||
@@ -93,6 +99,21 @@ public sealed class SqlCatalog
|
||||
&& TryMatch(columns, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an authored procedure name in a canonical schema to the catalog's spelling, by the same
|
||||
/// exact-then-unique-case-insensitive rule as tables and columns.
|
||||
/// </summary>
|
||||
/// <param name="canonicalSchema">The schema, as <see cref="TryResolveSchema"/> returned it.</param>
|
||||
/// <param name="authored">The authored procedure name.</param>
|
||||
/// <param name="canonical">The catalog spelling, when this returns true.</param>
|
||||
/// <returns>True when exactly one procedure matches.</returns>
|
||||
public bool TryResolveRoutine(string canonicalSchema, string authored, out string canonical)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return _routinesBySchema.TryGetValue(canonicalSchema, out var routines)
|
||||
&& TryMatch(routines, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>The dictionary key for a canonical relation.</summary>
|
||||
/// <param name="schema">The canonical schema.</param>
|
||||
/// <param name="table">The canonical table.</param>
|
||||
|
||||
@@ -81,16 +81,29 @@ public static class SqlCatalogGate
|
||||
// A named-query tag quotes no identifier into SQL: its statement is driver configuration (checked
|
||||
// read-only at construction) and its column/rowKey/timestamp names are matched against the result
|
||||
// set by the reader. There is nothing here to allow-list, so it passes through unchanged.
|
||||
// A Procedure-mode write block (lmxopcua#781) names a routine, which is resolved here like any other
|
||||
// identifier — for a Query tag too, whose read side needs no catalog.
|
||||
SqlTagDefinition? canonical;
|
||||
SqlCatalogRejection? rejection;
|
||||
if (definition.Model == SqlTagModel.Query)
|
||||
{
|
||||
accepted.Add(definition);
|
||||
canonical = definition;
|
||||
rejection = null;
|
||||
}
|
||||
else if (!TryCanonicalize(definition, catalog, dialect, out canonical, out rejection))
|
||||
{
|
||||
rejected.Add(rejection!);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
|
||||
accepted.Add(canonical!);
|
||||
else
|
||||
if (canonical!.Write is { Mode: SqlWriteMode.Procedure } write
|
||||
&& !TryResolveProcedure(canonical, write, catalog, dialect, out canonical, out rejection))
|
||||
{
|
||||
rejected.Add(rejection!);
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.Add(canonical!);
|
||||
}
|
||||
|
||||
return new SqlCatalogGateResult(accepted, rejected);
|
||||
@@ -162,6 +175,64 @@ public static class SqlCatalogGate
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The rejection <see cref="SqlCatalogRejection.Field"/> for a write block's procedure.</summary>
|
||||
internal const string ProcedureField = "Write.Procedure";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a Procedure-mode write block's routine against the catalog and substitutes its spelling
|
||||
/// (lmxopcua#781). Same rules as a table: at most two parts, each renderable, schema then routine by the
|
||||
/// exact-then-unique-case-insensitive match.
|
||||
/// </summary>
|
||||
private static bool TryResolveProcedure(
|
||||
SqlTagDefinition definition,
|
||||
SqlWriteSpec write,
|
||||
SqlCatalog catalog,
|
||||
ISqlDialect dialect,
|
||||
out SqlTagDefinition? canonical,
|
||||
out SqlCatalogRejection? rejection)
|
||||
{
|
||||
canonical = null;
|
||||
rejection = null;
|
||||
|
||||
if (dialect.ListRoutinesSql is null)
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
|
||||
$"the {dialect.Provider} provider has no stored-procedure catalog to validate the procedure against, " +
|
||||
"so a Procedure-mode write cannot be served on it");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = (write.Procedure ?? string.Empty).Split('.');
|
||||
if (parts.Length > MaxNameParts || parts.Any(p => !IsRenderableIdentifier(p, dialect)))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
|
||||
"the authored procedure name is not a usable one- or two-part identifier (empty, over-long, more than " +
|
||||
"two parts, or containing control or Unicode format characters); the value is withheld from this " +
|
||||
"message because it cannot be safely rendered");
|
||||
return false;
|
||||
}
|
||||
|
||||
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
|
||||
var authoredRoutine = parts[^1];
|
||||
if (!catalog.TryResolveSchema(authoredSchema, out var schema))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
|
||||
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveRoutine(schema, authoredRoutine, out var routine))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
|
||||
$"stored procedure '{authoredRoutine}' does not exist in schema '{schema}', or the connecting principal " +
|
||||
"may not execute it (or the name matches more than one procedure under a case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
canonical = definition with { Write = write with { Procedure = SqlCatalog.QualifiedKey(schema, routine) } };
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
|
||||
private static bool TryResolveRelation(
|
||||
SqlTagDefinition definition,
|
||||
|
||||
@@ -37,6 +37,8 @@ internal static class SqlCatalogLoader
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string RoutineNameColumn = "ROUTINE_NAME";
|
||||
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
@@ -50,6 +52,7 @@ internal static class SqlCatalogLoader
|
||||
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
|
||||
/// <param name="commandTimeout">Per-command server-side backstop.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <param name="authoredRoutines">Procedure-mode write targets (lmxopcua#781) whose schemas' procedures to list.</param>
|
||||
/// <returns>The loaded catalog.</returns>
|
||||
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
|
||||
internal static async Task<SqlCatalog> LoadAsync(
|
||||
@@ -57,7 +60,8 @@ internal static class SqlCatalogLoader
|
||||
ISqlDialect dialect,
|
||||
IReadOnlyCollection<string> authoredTables,
|
||||
TimeSpan commandTimeout,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyCollection<string>? authoredRoutines = null)
|
||||
{
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
@@ -124,7 +128,35 @@ internal static class SqlCatalogLoader
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
|
||||
// Procedure-mode write targets (lmxopcua#781): one routine listing per schema they name. A dialect with no
|
||||
// routine catalog loads nothing, and the gate then refuses those tags by name.
|
||||
var routinesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
if (dialect.ListRoutinesSql is { } listRoutinesSql)
|
||||
{
|
||||
foreach (var authored in authoredRoutines ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored)) continue;
|
||||
var parts = authored.Split('.');
|
||||
if (parts.Length > 2) continue;
|
||||
string schema;
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
schema = defaultSchema;
|
||||
}
|
||||
|
||||
if (routinesBySchema.ContainsKey(schema)) continue;
|
||||
routinesBySchema[schema] = await QueryColumnAsync(
|
||||
connection, dialect, listRoutinesSql, RoutineNameColumn,
|
||||
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable, routinesBySchema);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -15,11 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
|
||||
/// <see cref="SqlPollReader"/> owns the read path.
|
||||
/// <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
|
||||
/// the driver's <c>allowWrites</c>, the tag's own <c>"writable": true</c>, and a target its <c>write</c> mode can
|
||||
/// use (lmxopcua#781: Update / Upsert one keyed row, Insert any table-backed tag, Procedure any tag) — on top of
|
||||
/// the node's Writable flag and the caller's WriteOperate role, which the server checks first. It is one
|
||||
/// parameterised statement over the tag's catalog-validated identifiers (<see cref="SqlWritePlanner"/>), run in a
|
||||
/// transaction that commits only when a table write touched exactly one row (a procedure call commits when it
|
||||
/// does not throw). 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
|
||||
@@ -626,17 +627,28 @@ public sealed class SqlDriver
|
||||
/// 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));
|
||||
=> _options.AllowWrites && tag.Writable && HasWriteTarget(tag) && _dialect.SupportsWriteMode(tag.EffectiveWrite.Mode);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tag's model gives its write mode something to write (lmxopcua#781): a procedure always; an
|
||||
/// insert any table-backed tag; an update or upsert one keyed row — KeyValue, or a WideRow where-pair.
|
||||
/// </summary>
|
||||
private static bool HasWriteTarget(SqlTagDefinition tag) => tag.EffectiveWrite.Mode switch
|
||||
{
|
||||
SqlWriteMode.Procedure => true,
|
||||
SqlWriteMode.Insert => tag.Model is SqlTagModel.KeyValue or SqlTagModel.WideRow,
|
||||
_ => 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.
|
||||
// Each write is one parameterised statement in the tag's write mode (SqlWritePlanner; Update by default) over the
|
||||
// tag's CATALOG-VALIDATED definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no
|
||||
// unvalidated identifier (table, column or procedure) can reach a statement. It runs in a transaction: a table
|
||||
// write commits only when it touched one row (two for a MySQL upsert that updated) — zero rows is BadNoData, more
|
||||
// is BadConfigurationError (the selector is ambiguous, the read-side rule), and both roll back; a procedure call
|
||||
// commits when it does not throw. A constraint the database enforced maps to BadEntryExists / BadOutOfRange
|
||||
// (MapWriteFailure). Bounded by operationTimeout like a poll. Never retried (WriteIdempotent=false). 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)
|
||||
{
|
||||
@@ -656,7 +668,9 @@ public sealed class SqlDriver
|
||||
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;
|
||||
if (!tag.Writable || !HasWriteTarget(tag)) return SqlStatusCodes.BadNotWritable;
|
||||
// Deploy refuses a mode the provider cannot serve (SqlWriteModeUnsupported); this is the runtime backstop.
|
||||
if (!_dialect.SupportsWriteMode(tag.EffectiveWrite.Mode)) return SqlStatusCodes.BadNotSupported;
|
||||
|
||||
var planned = SqlWritePlanner.TryPlan(tag, write.Value, _dialect, out var plan);
|
||||
if (planned != SqlStatusCodes.Good) return planned;
|
||||
@@ -683,14 +697,29 @@ public sealed class SqlDriver
|
||||
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;
|
||||
// log sink. Nothing was committed. A constraint the database enforced is the value's fault, not the
|
||||
// link's (lmxopcua#781): a duplicate key is BadEntryExists, another integrity or data rule BadOutOfRange.
|
||||
// Everything else — a missing grant included — stays BadCommunicationError.
|
||||
var failure = _dialect.ClassifyWriteFailure(ex);
|
||||
_logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type} ({Failure}). Driver={DriverInstanceId}",
|
||||
tag.Name, Endpoint, ex.GetType().Name, failure, _driverInstanceId);
|
||||
return MapWriteFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.</summary>
|
||||
/// <summary>The status a classified write failure publishes (lmxopcua#781; documented in docs/drivers/Sql.md).</summary>
|
||||
internal static uint MapWriteFailure(SqlWriteFailure failure) => failure switch
|
||||
{
|
||||
SqlWriteFailure.UniqueViolation => SqlStatusCodes.BadEntryExists,
|
||||
SqlWriteFailure.IntegrityViolation or SqlWriteFailure.DataException => SqlStatusCodes.BadOutOfRange,
|
||||
_ => SqlStatusCodes.BadCommunicationError,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Opens a connection and runs one planned write in a transaction. A table write commits only when it touched
|
||||
/// between one and <see cref="SqlWritePlan.MaxRowsAffected"/> rows; a procedure call commits whenever it did
|
||||
/// not throw (its row count is the procedure's own business).
|
||||
/// </summary>
|
||||
private async Task<uint> ExecuteWriteAsync(SqlTagDefinition tag, SqlWritePlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
@@ -725,7 +754,7 @@ public sealed class SqlDriver
|
||||
rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rows == 1)
|
||||
if (plan.Mode == SqlWriteMode.Procedure || (rows >= 1 && rows <= plan.MaxRowsAffected))
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return SqlStatusCodes.Good;
|
||||
@@ -735,9 +764,9 @@ public sealed class SqlDriver
|
||||
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 " +
|
||||
"Sql {Mode} write to '{Tag}' touched {Rows} row(s) on '{Table}' and was rolled back: a write must touch " +
|
||||
"exactly one row. Driver={DriverInstanceId}",
|
||||
tag.Name, rows, tag.Table, _driverInstanceId);
|
||||
plan.Mode, tag.Name, rows, tag.Table, _driverInstanceId);
|
||||
return rows > 1 ? SqlStatusCodes.BadConfigurationError : SqlStatusCodes.BadNotSupported;
|
||||
}
|
||||
}
|
||||
@@ -1115,6 +1144,15 @@ public sealed class SqlDriver
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.Write is { } write && !_dialect.SupportsWriteMode(write.Mode))
|
||||
{
|
||||
// Deploy refuses this (SqlWriteModeUnsupported); an older artifact still reads, read-only.
|
||||
_logger.LogWarning(
|
||||
"Sql tag {RawPath} asks for {Mode} writes, which the {Provider} provider does not serve; it " +
|
||||
"is published read-only. Driver={DriverInstanceId}",
|
||||
entry.RawPath, write.Mode, _dialect.Provider, _driverInstanceId);
|
||||
}
|
||||
|
||||
table[entry.RawPath] = definition;
|
||||
}
|
||||
else
|
||||
@@ -1197,10 +1235,17 @@ public sealed class SqlDriver
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var catalog = tables.Length == 0
|
||||
// Procedure-mode write targets (lmxopcua#781) are identifiers too — a Query tag's included.
|
||||
var routines = authored.Values
|
||||
.Where(definition => definition.Write is { Mode: SqlWriteMode.Procedure })
|
||||
.Select(definition => definition.Write!.Procedure!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var catalog = tables.Length == 0 && routines.Length == 0
|
||||
? SqlCatalog.Empty
|
||||
: await RunBoundedAsync(
|
||||
token => LoadCatalogAsync(tables, token),
|
||||
token => LoadCatalogAsync(tables, routines, token),
|
||||
_options.OperationTimeout,
|
||||
"the catalog queries did not return",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
@@ -1235,7 +1280,8 @@ public sealed class SqlDriver
|
||||
|
||||
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
|
||||
private async Task<SqlCatalog> LoadCatalogAsync(
|
||||
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
|
||||
IReadOnlyCollection<string> authoredTables, IReadOnlyCollection<string> authoredRoutines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
@@ -1246,7 +1292,7 @@ public sealed class SqlDriver
|
||||
connection.ConnectionString = _connectionString;
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await SqlCatalogLoader
|
||||
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
|
||||
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken, authoredRoutines)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,7 +1257,22 @@ public static class SqlStatusCodes
|
||||
/// </summary>
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
|
||||
/// <summary>The provider could not report how many rows a write touched, so it was rolled back.</summary>
|
||||
/// <summary>
|
||||
/// A write the database refused for a unique / primary-key violation (lmxopcua#781) — an Insert of a key that
|
||||
/// exists, typically. (<c>BadEntryExists</c>, read off <c>Opc.Ua.StatusCodes</c>.)
|
||||
/// </summary>
|
||||
public const uint BadEntryExists = 0x809F0000u;
|
||||
|
||||
/// <summary>
|
||||
/// A write the database refused for another integrity rule (NOT NULL, foreign key, CHECK) or a data exception
|
||||
/// (overflow, truncation, an unconvertible value) — lmxopcua#781. (<c>BadOutOfRange</c>.)
|
||||
/// </summary>
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>
|
||||
/// The provider could not report how many rows a write touched, so it was rolled back — or the dialect does not
|
||||
/// serve the tag's write mode (Upsert / Procedure on Odbc, lmxopcua#781).
|
||||
/// </summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
|
||||
|
||||
@@ -4,94 +4,235 @@ 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.
|
||||
/// One compiled write (lmxopcua#753, #781): a parameterised statement 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="ParameterBindNames">
|
||||
/// The distinct bind names, in first-marker order (a positional provider binds in this order, and only the modes
|
||||
/// it supports — Update and Insert — are planned for it, where every marker appears exactly once).
|
||||
/// </param>
|
||||
/// <param name="Parameters">The bound values, aligned with <paramref name="ParameterBindNames"/>.</param>
|
||||
/// <param name="Mode">The statement shape — decides how the executor reads the affected-row count.</param>
|
||||
/// <param name="MaxRowsAffected">
|
||||
/// The most rows the statement may report for a success: 1, or 2 for a MySQL upsert. Ignored for
|
||||
/// <see cref="SqlWriteMode.Procedure"/>, whose row count is the procedure's business.
|
||||
/// </param>
|
||||
public sealed record SqlWritePlan(
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters);
|
||||
IReadOnlyList<object?> Parameters,
|
||||
SqlWriteMode Mode = SqlWriteMode.Update,
|
||||
int MaxRowsAffected = 1);
|
||||
|
||||
/// <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.
|
||||
/// Turns one OPC UA write into one parameterised statement (design §3.4, lmxopcua#753 / #781 item 2) — pure, no
|
||||
/// I/O. The tag's <c>write</c> block picks the shape; there is no authored write SQL:
|
||||
/// <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>
|
||||
/// <item><see cref="SqlWriteMode.Update"/> (the default): <c>UPDATE t SET value [, stamp] WHERE key = @k</c>
|
||||
/// on a KeyValue tag or a WideRow where-pair.</item>
|
||||
/// <item><see cref="SqlWriteMode.Insert"/>: <c>INSERT INTO t (key, value [, stamp]) VALUES (…)</c> — no key
|
||||
/// column for a WideRow <c>topByTimestamp</c> tag (an append).</item>
|
||||
/// <item><see cref="SqlWriteMode.Upsert"/>: the dialect's single-statement upsert
|
||||
/// (<see cref="ISqlDialect.UpsertSql"/>).</item>
|
||||
/// <item><see cref="SqlWriteMode.Procedure"/>: the dialect's call of the catalog-validated procedure
|
||||
/// (<see cref="ISqlDialect.ProcedureCallSql"/>) with the value, and optionally the key and write time, bound.</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>
|
||||
/// <para><b>Injection boundary.</b> Every identifier is one the catalog gate resolved and substituted (the driver
|
||||
/// plans writes only for gate-accepted definitions) and reaches the text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/>; procedure parameter names are restricted to a plain identifier
|
||||
/// charset by the tag parser; the value, the key and the stamp 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";
|
||||
private const string TimestampBaseName = "t";
|
||||
|
||||
/// <summary>
|
||||
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
|
||||
/// </summary>
|
||||
/// <summary>Plans a write of <paramref name="value"/> to <paramref name="tag"/>, stamping the current UTC time.</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="dialect">Quoting, markers, bind names and the dialect-specific statements.</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)
|
||||
=> TryPlan(tag, value, dialect, DateTime.UtcNow, out plan);
|
||||
|
||||
/// <summary>
|
||||
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
|
||||
/// <see cref="SqlStatusCodes.BadNotWritable"/>: the model has no target for the mode (a Query tag outside
|
||||
/// Procedure, a <c>topByTimestamp</c> row for Update/Upsert). <see cref="SqlStatusCodes.BadNotSupported"/>: the
|
||||
/// dialect does not serve the mode. <see cref="SqlStatusCodes.BadTypeMismatch"/>: the value will not coerce.
|
||||
/// </summary>
|
||||
/// <param name="tag">The catalog-validated definition.</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="dialect">Quoting, markers, bind names and the dialect-specific statements.</param>
|
||||
/// <param name="utcNow">The write time bound to a stamp column or <c>timestampParam</c> (UTC).</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, DateTime utcNow, 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;
|
||||
}
|
||||
var write = tag.EffectiveWrite;
|
||||
if (!dialect.SupportsWriteMode(write.Mode)) return SqlStatusCodes.BadNotSupported;
|
||||
|
||||
if (!TryConvert(value, tag.DeclaredType, out var bound)) return SqlStatusCodes.BadTypeMismatch;
|
||||
var stamp = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc);
|
||||
|
||||
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));
|
||||
return write.Mode == SqlWriteMode.Procedure
|
||||
? PlanProcedure(tag, write, bound, stamp, dialect, out plan)
|
||||
: PlanTableWrite(tag, write, bound, stamp, dialect, out plan);
|
||||
}
|
||||
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.
|
||||
plan = null;
|
||||
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]);
|
||||
/// <summary>Update / Insert / Upsert against the tag's own table.</summary>
|
||||
private static uint PlanTableWrite(
|
||||
SqlTagDefinition tag, SqlWriteSpec write, object? bound, DateTime stamp, ISqlDialect dialect, out SqlWritePlan? plan)
|
||||
{
|
||||
plan = null;
|
||||
string valueColumn;
|
||||
string? keyColumn;
|
||||
string? keyValue;
|
||||
switch (tag.Model)
|
||||
{
|
||||
case SqlTagModel.KeyValue when !Blank(tag.ValueColumn) && !Blank(tag.KeyColumn) && tag.KeyValue is not null:
|
||||
valueColumn = tag.ValueColumn!;
|
||||
keyColumn = tag.KeyColumn!;
|
||||
keyValue = tag.KeyValue;
|
||||
break;
|
||||
case SqlTagModel.WideRow when !Blank(tag.ColumnName) && !Blank(tag.RowSelectorColumn)
|
||||
&& tag.RowSelectorValue is not null:
|
||||
valueColumn = tag.ColumnName!;
|
||||
keyColumn = tag.RowSelectorColumn!;
|
||||
keyValue = tag.RowSelectorValue;
|
||||
break;
|
||||
// Newest-row selector: an Insert appends a row (no key); Update/Upsert have no row to aim at — "the
|
||||
// newest row" moves under a concurrent insert.
|
||||
case SqlTagModel.WideRow when write.Mode == SqlWriteMode.Insert && !Blank(tag.ColumnName):
|
||||
valueColumn = tag.ColumnName!;
|
||||
keyColumn = null;
|
||||
keyValue = null;
|
||||
break;
|
||||
default:
|
||||
return SqlStatusCodes.BadNotWritable;
|
||||
}
|
||||
|
||||
var stampColumn = write.StampTimestamp ? SqlWriteSpecParser.StampColumn(tag) : null;
|
||||
if (write.StampTimestamp && stampColumn is null) return SqlStatusCodes.BadConfigurationError;
|
||||
|
||||
var table = QuoteQualifiedName(tag.Table, dialect);
|
||||
var valueMarker = dialect.ParameterMarker(ValueBaseName);
|
||||
var keyMarker = dialect.ParameterMarker(KeyBaseName);
|
||||
var stampMarker = dialect.ParameterMarker(TimestampBaseName);
|
||||
|
||||
// The value first, then the stamp: the order of the SET list and of the insert's non-key columns.
|
||||
var assignments = new List<(string QuotedColumn, string Marker)> { (dialect.QuoteIdentifier(valueColumn), valueMarker) };
|
||||
if (stampColumn is not null) assignments.Add((dialect.QuoteIdentifier(stampColumn), stampMarker));
|
||||
|
||||
var binds = new Binds(dialect);
|
||||
string sql;
|
||||
var maxRows = 1;
|
||||
switch (write.Mode)
|
||||
{
|
||||
case SqlWriteMode.Update:
|
||||
// Marker order: SET's value [and stamp], then WHERE's key — a positional provider binds in this order.
|
||||
sql = string.Concat(
|
||||
"UPDATE ", table,
|
||||
" SET ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)),
|
||||
" WHERE ", dialect.QuoteIdentifier(keyColumn!), " = ", keyMarker);
|
||||
binds.Add(ValueBaseName, bound);
|
||||
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
|
||||
binds.Add(KeyBaseName, keyValue);
|
||||
break;
|
||||
|
||||
case SqlWriteMode.Insert:
|
||||
{
|
||||
var columns = new List<string>();
|
||||
var markers = new List<string>();
|
||||
if (keyColumn is not null)
|
||||
{
|
||||
columns.Add(dialect.QuoteIdentifier(keyColumn));
|
||||
markers.Add(keyMarker);
|
||||
binds.Add(KeyBaseName, keyValue);
|
||||
}
|
||||
|
||||
columns.AddRange(assignments.Select(a => a.QuotedColumn));
|
||||
markers.AddRange(assignments.Select(a => a.Marker));
|
||||
binds.Add(ValueBaseName, bound);
|
||||
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
|
||||
sql = string.Concat(
|
||||
"INSERT INTO ", table, " (", string.Join(", ", columns), ") VALUES (", string.Join(", ", markers), ")");
|
||||
break;
|
||||
}
|
||||
|
||||
case SqlWriteMode.Upsert:
|
||||
{
|
||||
var upsert = dialect.UpsertSql(table, dialect.QuoteIdentifier(keyColumn!), keyMarker, assignments);
|
||||
if (upsert is null) return SqlStatusCodes.BadNotSupported;
|
||||
sql = upsert;
|
||||
// A named-marker dialect (the only kind with an upsert) binds by name, so first-appearance order is
|
||||
// documentation rather than load-bearing here.
|
||||
binds.Add(KeyBaseName, keyValue);
|
||||
binds.Add(ValueBaseName, bound);
|
||||
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
|
||||
maxRows = Math.Max(1, dialect.UpsertMaxRowsAffected);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return SqlStatusCodes.BadNotWritable;
|
||||
}
|
||||
|
||||
plan = new SqlWritePlan(sql, binds.Names, binds.Values, write.Mode, maxRows);
|
||||
return SqlStatusCodes.Good;
|
||||
}
|
||||
|
||||
/// <summary>A call of the tag's catalog-validated procedure: value, then key, then write time — each when bound.</summary>
|
||||
private static uint PlanProcedure(
|
||||
SqlTagDefinition tag, SqlWriteSpec write, object? bound, DateTime stamp, ISqlDialect dialect, out SqlWritePlan? plan)
|
||||
{
|
||||
plan = null;
|
||||
if (Blank(write.Procedure)) return SqlStatusCodes.BadConfigurationError;
|
||||
|
||||
var binds = new Binds(dialect);
|
||||
var arguments = new List<(string Parameter, string Marker)>
|
||||
{
|
||||
(write.ValueParameter ?? SqlWriteSpecParser.DefaultValueParameter, dialect.ParameterMarker(ValueBaseName)),
|
||||
};
|
||||
binds.Add(ValueBaseName, bound);
|
||||
|
||||
if (write.KeyParameter is { } keyParameter)
|
||||
{
|
||||
var key = SqlWriteSpecParser.KeyValue(tag);
|
||||
if (key is null) return SqlStatusCodes.BadConfigurationError;
|
||||
arguments.Add((keyParameter, dialect.ParameterMarker(KeyBaseName)));
|
||||
binds.Add(KeyBaseName, key);
|
||||
}
|
||||
|
||||
if (write.TimestampParameter is { } timestampParameter)
|
||||
{
|
||||
arguments.Add((timestampParameter, dialect.ParameterMarker(TimestampBaseName)));
|
||||
binds.Add(TimestampBaseName, stamp);
|
||||
}
|
||||
|
||||
var sql = dialect.ProcedureCallSql(QuoteQualifiedName(write.Procedure!, dialect), arguments);
|
||||
if (sql is null) return SqlStatusCodes.BadNotSupported;
|
||||
|
||||
plan = new SqlWritePlan(sql, binds.Names, binds.Values, SqlWriteMode.Procedure);
|
||||
return SqlStatusCodes.Good;
|
||||
}
|
||||
|
||||
@@ -116,9 +257,23 @@ public static class SqlWritePlanner
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.table</c> name.</summary>
|
||||
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.name</c>.</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);
|
||||
|
||||
/// <summary>The ordered (bind name, value) list a plan carries.</summary>
|
||||
private sealed class Binds(ISqlDialect dialect)
|
||||
{
|
||||
public List<string> Names { get; } = [];
|
||||
|
||||
public List<object?> Values { get; } = [];
|
||||
|
||||
public void Add(string baseName, object? value)
|
||||
{
|
||||
Names.Add(dialect.ParameterBindName(baseName));
|
||||
Values.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user