refactor(sql): dialect-owned parameter markers, bind names and command configuration

ISqlDialect gains ParameterMarker (the token in the SQL text), ParameterBindName
(the DbParameter.ParameterName) and ConfigureCommand (per-command provider setup,
where Oracle will set BindByName). SQL Server and the test SQLite dialect answer
"@"+name for both spellings and no-op the hook, so SQL Server behaviour is
byte-identical — every existing expected-SQL assertion passes untouched.

SqlGroupPlanner drops its hardcoded @k/@w consts and mints base names (k0..kN, w)
that the dialect spells; SqlQueryPlan carries the emitted markers AND the matching
bind names, so SqlPollReader keeps copying the plan's pairing rather than
re-deriving a provider convention (the two diverge on ODP.NET: ":k0" in the text,
bare "k0" on the parameter). SqlCatalogLoader and SqlBrowseSession bind
schema/table through the dialect, so their bind names cannot drift from the
markers in the dialect-owned catalog SQL. Every parameter-carrying command site
calls ConfigureCommand before its first parameter.

Controlled by a new test-only ColonMarkerDialect (":name" markers, bare bind
names) that really executes against the SQLite fixture. Each leg was falsified
against the pre-change behaviour: the planner emitted "@k0", the reader bound
":k0", and a hardcoded "@schema" in the loader failed catalog load outright.
This commit is contained in:
Joseph Doherty
2026-08-05 06:25:53 -04:00
parent 08c1728a98
commit 1da770253e
16 changed files with 527 additions and 36 deletions
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
/// every provider: ODP.NET's text says <c>:k0</c> while its <see cref="DbParameter.ParameterName"/> is the
/// bare <c>k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="ParameterBindNames">
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];